Compare commits

..

3 Commits

Author SHA1 Message Date
b1b010deee Adding approval process 2023-07-03 17:26:43 -04:00
a43264189f Add tests for endpoints 2023-07-03 09:18:48 -04:00
c2b09b3650 add deps 2023-07-03 09:18:41 -04:00
15 changed files with 687 additions and 79 deletions

View File

@@ -2,4 +2,47 @@
#+AUTHOR: Adam Mohammed
This provides a way to do k8s native application deployment in a way that's simple and requires almost no configuration
Service Demon is a centralized configuration provider for Nautilus services.
This provides a way to do k8s native application deployment in a way that's simple
and requires almost no configuration on the client.
Service Demon runs in k8s and expects a service agent to be deployed alongside
your application.
** Workflow
This demon (playing off of "daemon") hosts an application registration process that,
the our agent is aware of. By simply deploying the agent in your namespace, it will kick off
the application registration process. On completion, the agent is able to respond to commands
from the configuration service to update k8s resources that your application can rely on.
The agent on deploy, will use TLS certificates generated for your applications ingress to
announce itself as an application that wishes to be registered.
Once the app announces that it would like to be registered, an authorized human must approve
the application.
Once the approval goes through, the application is registered, and can start to request application
configuration manifests.
The agent will fetch the manifests it needs and store them by talking to the k8s api. It will create
configuration maps, secrets, and other resources as necessary.
From there a client library loaded into your application will know how to read those manifests
and provide some baseline functionality to your service.
** Motivation
Although microservices are autonomous, they rely on common infrastructure to
reduce the operational overhead on the team maintaining them. Right now,
Nautilus has trouble performing authentication and authorization checks,
particularly between services.
By using a central configuration store, we can deploy and manage authorization policies
centrally, and push them down to the active services, so we can control authorization at runtime.

View File

@@ -1,74 +1,78 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io/ioutil"
"crypto/tls"
"crypto/x509"
"io"
"log"
"net/http"
"os"
"golang.org/x/oauth2"
"go.fixergrid.net/servicedemon/pkg/discord"
"go.fixergrid.net/servicedemon/pkg/pubsub"
"go.fixergrid.net/servicedemon/pkg/registrar"
)
func main() {
fmt.Println("Starting .... the >HUB<")
token, isSet := os.LookupEnv("DISCORD_BOT_TOKEN")
if !isSet {
fmt.Println("please set the environment variable 'DISCORD_BOT_TOKEN'")
return
}
tokensrc := oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: token,
TokenType: "Bot",
})
ctx := context.Background()
client := discord.
NewClient(ctx, tokensrc).
WithDefaultChannel("1125162127133523978")
resp, err := client.Get("https://discord.com/api/v10/channels/1125162127133523978")
if err != nil {
fmt.Printf("there was an error making the request: %v\n", err)
return
}
out, err := ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Printf("failed to read response body: %v\n", err)
return
}
fmt.Printf("Got response:\n%s\n", out)
messageContent := map[string]interface{}{
"content": "Hello, world!",
}
b, err := json.Marshal(messageContent)
if err != nil {
fmt.Printf("failed to marshal message: %v\n", err)
return
}
r2, err := client.Post("https://discord.com/api/v10/channels/1125162127133523978/messages", "application/json", bytes.NewReader(b))
if err != nil {
fmt.Printf("failed tdo send message to server: %v\n", err)
return
}
out, err = ioutil.ReadAll(r2.Body)
if err != nil {
fmt.Printf("failed to read response body on send message: %v\n", err)
return
}
fmt.Printf("status: %v - res: %v\n", r2.Status, out)
type noopHandler struct {
http.HandlerFunc
}
func wrapHandlefunc(h http.HandlerFunc) noopHandler {
return noopHandler{
HandlerFunc: h,
}
}
func (h noopHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.HandlerFunc(w, req)
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
logger := log.New(os.Stdout, "main: ", log.LstdFlags|log.Lshortfile)
logger.Println("Starting .... the >HUB<")
pubsub := pubsub.New()
repo := registrar.NewRepo()
r := registrar.NewRegistrar(
pubsub,
repo,
registrar.WithLogger(log.New(os.Stdout, "registrar: ", log.LstdFlags|log.Lshortfile)),
)
al := registrar.NewApprovalListener(
pubsub,
nil,
registrar.OptionLog(log.New(os.Stdout, "approvalListener: ", log.LstdFlags|log.Lshortfile)),
)
go al.Run(ctx)
mux := http.NewServeMux()
mux.HandleFunc("/register", r.HandleRegistration)
mux.Handle("/approvals/", http.StripPrefix("/approvals/", wrapHandlefunc(r.HandleApproval)))
certFile, err := os.Open("./certs/ca.pem")
if err != nil {
logger.Fatalf("failed to open ca.pem: %v", err)
}
caCert, err := io.ReadAll(certFile)
if err != nil {
logger.Fatalf("failed to read in ca: %v", err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caCert)
server := &http.Server{
Addr: ":3001",
TLSConfig: &tls.Config{
ClientCAs: pool,
ClientAuth: tls.RequireAndVerifyClientCert,
},
}
server.Handler = mux
log.Println(server.ListenAndServeTLS("./certs/combined.pem", "./certs/server-key.pem"))
}

9
go.mod
View File

@@ -3,16 +3,19 @@ module go.fixergrid.net/servicedemon
go 1.20
require (
github.com/bwmarrin/discordgo v0.27.1
github.com/slack-go/slack v0.12.2
github.com/stretchr/testify v1.8.4
golang.org/x/oauth2 v0.9.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/crypto v0.10.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/net v0.11.0 // indirect
golang.org/x/sys v0.9.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

29
go.sum
View File

@@ -1,31 +1,34 @@
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/slack-go/slack v0.12.2 h1:x3OppyMyGIbbiyFhsBmpf9pwkUzMhthJMRNmNlA4LaQ=
github.com/slack-go/slack v0.12.2/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/oauth2 v0.9.0 h1:BPpt2kU7oMRq3kCHAA1tbSEshXRw1LpG2ztgDwrzuAs=
golang.org/x/oauth2 v0.9.0/go.mod h1:qYgFZaFiu6Wg24azG8bdV52QJXJGbZzIIsRCdVKzbLw=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
@@ -34,3 +37,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

51
pkg/pubsub/pubsub.go Normal file
View File

@@ -0,0 +1,51 @@
package pubsub
import "sync"
type Pubsub struct {
mu sync.RWMutex
subs map[string][]chan string
closed bool
}
func New() *Pubsub {
ps := Pubsub{}
ps.subs = make(map[string][]chan string)
return &ps
}
func (ps *Pubsub) Publish(topic string, msg string) {
ps.mu.RLock()
defer ps.mu.RUnlock()
if ps.closed {
return
}
for _, ch := range ps.subs[topic] {
ch <- msg
}
}
func (ps *Pubsub) Subscribe(topic string) <-chan string {
ps.mu.Lock()
defer ps.mu.Unlock()
ch := make(chan string, 1)
ps.subs[topic] = append(ps.subs[topic], ch)
return ch
}
func (ps *Pubsub) Close() {
ps.mu.Lock()
defer ps.mu.Unlock()
if !ps.closed {
ps.closed = true
for _, subs := range ps.subs {
for _, ch := range subs {
close(ch)
}
}
}
}

87
pkg/registrar/approval.go Normal file
View File

@@ -0,0 +1,87 @@
package registrar
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
svc "go.fixergrid.net/servicedemon/pkg/registrar/internal/services"
)
const APPROVAL_TOPIC = "net.fixergrid.events.app.approved"
type ApprovalListener struct {
svc.ApprovalRequester
svc.PubSub
svc.AppRepo
log internalLog
}
type option func(r *ApprovalListener)
func OptionLog(l logger) option {
return func(r *ApprovalListener) {
r.log = internalLog{l}
}
}
func NewApprovalListener(ps svc.PubSub, requester svc.ApprovalRequester, options ...option) ApprovalListener {
out := ApprovalListener{
PubSub: ps,
ApprovalRequester: requester,
}
for _, opt := range options {
opt(&out)
}
return out
}
func (a ApprovalListener) Run(ctx context.Context) {
newAppEvents := a.Subscribe(REGISTRATION_TOPIC)
approvalEvents := a.Subscribe(APPROVAL_TOPIC)
for {
select {
case <-ctx.Done():
return
case event := <-newAppEvents:
msg, err := readMessage(event)
if err != nil {
a.log.Printf("failed to read message: got '%s': %v", event, err)
}
a.SendApprovalRequest(msg["name"])
case event := <-approvalEvents:
msg, err := readMessage(event)
if err != nil {
a.log.Printf("approvalevent: failed to read message: got '%s': %v", event, err)
}
id := uuid.MustParse(msg["id"])
a.HandleApprovedRequest(id)
}
}
}
func (a ApprovalListener) SendApprovalRequest(name string) {
a.log.Printf("sent approval message for [%s]", name)
}
func (a ApprovalListener) HandleApprovedRequest(id uuid.UUID) {
res, err := a.ApproveApp(id)
if err != nil {
a.log.Printf("ERROR: couldn't approve request: %v", err)
return
}
a.log.Printf("approved application [%s]", res.ID)
}
func readMessage(event string) (map[string]string, error) {
out := map[string]string{}
err := json.Unmarshal([]byte(event), &out)
if err != nil {
return nil, fmt.Errorf("readMessage: %w", err)
}
return out, nil
}

109
pkg/registrar/endpoints.go Normal file
View File

@@ -0,0 +1,109 @@
package registrar
import (
"encoding/json"
"fmt"
"net/http"
"github.com/google/uuid"
svc "go.fixergrid.net/servicedemon/pkg/registrar/internal/services"
)
const (
REGISTRATION_TOPIC = "net.fixergrid.events.app.admission"
)
var validApprover string = "Nautilus Admins"
type Registrar struct {
svc.Publisher
repo svc.AppRepo
log internalLog
}
func WithLogger(l logger) RegistrarOpt {
return func(r *Registrar) {
r.log = internalLog{l}
}
}
type RegistrarOpt func(*Registrar)
func NewRegistrar(publisher svc.Publisher, repo svc.AppRepo, options ...RegistrarOpt) Registrar {
r := Registrar{
Publisher: publisher,
repo: repo,
}
for _, opt := range options {
opt(&r)
}
return r
}
func (r Registrar) HandleRegistration(resp http.ResponseWriter, req *http.Request) {
cert := req.TLS.PeerCertificates[0]
name := cert.DNSNames[0]
if r.repo.IsRegistered(name) {
resp.WriteHeader(http.StatusOK)
resp.Write([]byte(`{"status": "registered"}`))
} else {
id := r.repo.StartAppRegistration(name)
event := map[string]string{
"id": id.String(),
"name": name,
"type": "ApplicationRegistrationSubmitted",
}
r.sendEvent(REGISTRATION_TOPIC, event)
resp.WriteHeader(http.StatusCreated)
fmt.Fprintf(resp, `{"status": "pending_approval"}`)
}
}
func (r Registrar) HandleApproval(resp http.ResponseWriter, req *http.Request) {
headers := resp.Header()
headers.Set("content-type", "application/json")
in := req.URL.Path
cert := req.TLS.PeerCertificates[0]
OUs := cert.Subject.OrganizationalUnit
var ou string
if len(OUs) == 1 {
ou = OUs[0]
} else {
fmt.Fprintf(resp, `{"errors": ["missing OU"]}`)
resp.WriteHeader(http.StatusBadRequest)
return
}
if ou != validApprover {
fmt.Fprintf(resp, `{"errors": ["approval forbidden"]}`)
resp.WriteHeader(http.StatusForbidden)
return
}
appID := uuid.MustParse(in)
event := map[string]string{
"id": appID.String(),
"approver": ou,
"type": "ApplicationRegistrationApproved",
}
r.sendEvent(APPROVAL_TOPIC, event)
resp.WriteHeader(http.StatusOK)
fmt.Fprintf(resp, `{"status": "registered"}`)
}
func (r Registrar) sendEvent(topic string, event map[string]string) error {
b, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("registrar: sendEvent: failed to marshal: %w", err)
}
r.log.Printf("sending event: %s", event)
r.Publish(topic, string(b))
return nil
}

View File

@@ -0,0 +1,155 @@
package registrar
import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.fixergrid.net/servicedemon/pkg/pubsub"
)
type TestRequest http.Request
func NewTestRequest(t *testing.T, method string, url string, body io.Reader) TestRequest {
req, err := http.NewRequest(method, url, body)
require.NoError(t, err)
return TestRequest(*req)
}
func (tr TestRequest) WithFakeTLSState(dnsName string) TestRequest {
cert := x509.Certificate{
DNSNames: []string{
dnsName,
},
}
tr.TLS = &tls.ConnectionState{
PeerCertificates: []*x509.Certificate{&cert},
}
return tr
}
func (tr TestRequest) Create() *http.Request {
req := http.Request(tr)
return &req
}
type AppState struct {
Name string
State string
}
type TestAppRepo []AppState
func (repo TestAppRepo) IsRegistered(name string) bool {
for _, app := range repo {
if name == app.Name && app.State == "registered" {
return true
}
}
return false
}
func (repo TestAppRepo) PendingApprovalCount() int {
count := 0
for _, app := range repo {
if app.State == "pending_approval" {
count += 1
}
}
return count
}
func (repo *TestAppRepo) StartAppRegistration(name string) uuid.UUID {
*repo = append(*repo, AppState{
Name: name,
State: "pending_approval",
})
return uuid.New()
}
func TestHandleRegisterPendingApproval(t *testing.T) {
resp := httptest.NewRecorder()
req := NewTestRequest(t, http.MethodPost, "http://example.com/v1/register", nil).
WithFakeTLSState("dev-app-1.delivery.engineering").
Create()
repo := &TestAppRepo{}
pubsub := pubsub.New()
subscriber := pubsub.Subscribe(REGISTRATION_TOPIC)
NewRegistrar(pubsub, repo).HandleRegistration(resp, req)
assert.Equal(t, http.StatusCreated, resp.Code)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, []byte(`{"status": "pending_approval"}`), body)
assert.Equal(t, 1, repo.PendingApprovalCount())
expectedEvent := map[string]string{
"name": "dev-app-1.delivery.engineering",
"type": "ApplicationRegistrationSubmitted",
}
assertMessage(t, expectedEvent, subscriber)
}
func TestHandleRegisterAlreadyRegistered(t *testing.T) {
resp := httptest.NewRecorder()
req := NewTestRequest(t, http.MethodPost, "http://example.com/v1/register", nil).
WithFakeTLSState("dev-app-1.delivery.engineering").
Create()
pubsub := pubsub.New()
repo := &TestAppRepo{
AppState{
Name: "dev-app-1.delivery.engineering",
State: "registered",
},
}
NewRegistrar(pubsub, repo).HandleRegistration(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, []byte(`{"status": "registered"}`), body)
}
func assertMessage(t *testing.T, expected map[string]string, sender <-chan string) {
timer := time.After(2 * time.Second)
for {
select {
case msg := <-sender:
compareMessage(t, expected, msg)
return
case <-timer:
t.Fatal("failed waiting for message")
}
}
}
func compareMessage(t *testing.T, expected map[string]string, observed string) {
observedEvent := map[string]string{}
require.NoError(t, json.Unmarshal([]byte(observed), &observedEvent))
assert.NotEmpty(t, observedEvent["id"])
for k, v := range expected {
assert.Equal(t, v, observedEvent[k])
}
}

View File

@@ -0,0 +1,59 @@
package persistence
import (
"github.com/google/uuid"
svc "go.fixergrid.net/servicedemon/pkg/registrar/internal/services"
)
type FakeRepo struct {
apps []*svc.Application
}
func NewFakeRepo() *FakeRepo {
return &FakeRepo{
apps: make([]*svc.Application, 0),
}
}
func (repo FakeRepo) IsRegistered(name string) bool {
for _, app := range repo.apps {
if name == app.Name && app.State == "registered" {
return true
}
}
return false
}
func (repo FakeRepo) PendingApprovalCount() int {
count := 0
for _, app := range repo.apps {
if app.State == "pending_approval" {
count += 1
}
}
return count
}
func (repo *FakeRepo) StartAppRegistration(name string) uuid.UUID {
repo.apps = append(repo.apps, &svc.Application{
Name: name,
State: "pending_approval",
})
return uuid.New()
}
func (repo *FakeRepo) ApproveApp(id uuid.UUID) (svc.Application, error) {
var app *svc.Application
for _, application := range repo.apps {
if application.ID == id {
application.State = "registered"
}
}
if app == nil {
return svc.Application{}, svc.ErrApplicationNotFound
}
return *app, nil
}

View File

@@ -0,0 +1,2 @@
package services

View File

@@ -0,0 +1,5 @@
package services
type ApprovalRequester interface {
SendApprovalRequest(appName string) error
}

View File

@@ -0,0 +1,14 @@
package services
type Publisher interface {
Publish(topic string, message string)
}
type PubSub interface {
Publisher
Subscriber
}
type Subscriber interface {
Subscribe(topic string) <-chan string
}

View File

@@ -0,0 +1,26 @@
package services
import (
"errors"
"fmt"
"github.com/google/uuid"
)
var (
ErrAppRepo = errors.New("AppRepo")
ErrApplicationNotFound = fmt.Errorf("%w: application not found", ErrAppRepo)
)
type Application struct {
ID uuid.UUID
Name string
State string
}
type AppRepo interface {
IsRegistered(name string) bool
PendingApprovalCount() int
StartAppRegistration(name string) uuid.UUID
ApproveApp(id uuid.UUID) (Application, error)
}

33
pkg/registrar/logging.go Normal file
View File

@@ -0,0 +1,33 @@
package registrar
import "fmt"
type logger interface {
Output(int, string) error
}
type ilogger interface {
logger
Print(...interface{})
Printf(string, ...interface{})
Println(...interface{})
}
type internalLog struct {
logger
}
// Println replicates the behaviour of the standard logger.
func (t internalLog) Println(v ...interface{}) {
t.Output(2, fmt.Sprintln(v...))
}
// Printf replicates the behaviour of the standard logger.
func (t internalLog) Printf(format string, v ...interface{}) {
t.Output(2, fmt.Sprintf(format, v...))
}
// Print replicates the behaviour of the standard logger.
func (t internalLog) Print(v ...interface{}) {
t.Output(2, fmt.Sprint(v...))
}

10
pkg/registrar/repo.go Normal file
View File

@@ -0,0 +1,10 @@
package registrar
import (
persist "go.fixergrid.net/servicedemon/pkg/registrar/internal/persistence"
svc "go.fixergrid.net/servicedemon/pkg/registrar/internal/services"
)
func NewRepo() svc.AppRepo {
return persist.NewFakeRepo()
}