Compare commits

...

9 Commits

29 changed files with 1344 additions and 109 deletions

4
.gitignore vendored
View File

@@ -1,5 +1,7 @@
# Devenv # Devenv
.devenv* .devenv*
devenv.local.nix devenv.local.nix
.certs
cmd/hub/hub
cmd/spoke-agent/spoke-agent

View File

@@ -2,4 +2,47 @@
#+AUTHOR: Adam Mohammed #+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.

14
appconfig/endpoints.go Normal file
View File

@@ -0,0 +1,14 @@
package appconfig
import (
"fmt"
"net/http"
)
func handleAuthzConfig(w http.ResponseWriter, req *http.Request) {
headers := w.Header()
headers.Set("content-type", "text/plain")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "hello, world")
}

33
appconfig/log.go Normal file
View File

@@ -0,0 +1,33 @@
package appconfig
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...))
}

31
appconfig/provider.go Normal file
View File

@@ -0,0 +1,31 @@
package appconfig
import (
"log"
"net/http"
"os"
)
type Provider struct {
handler http.Handler
log ilogger
}
func NewProvider() Provider {
return Provider{
handler: router(),
log: log.New(os.Stderr, "servicedemon/appconfig - ", log.LstdFlags|log.Lshortfile),
}
}
func (p Provider) Handler() http.Handler {
return p.handler
}
func router() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/config/authz", handleAuthzConfig)
return mux
}

174
cmd/hub/gencerts.sh Executable file
View File

@@ -0,0 +1,174 @@
#!/usr/bin/env nix-shell
#! nix-shell -i bash --pure
#! nix-shell -p bash cfssl openssl
#! nix-shell -I nixpkgs=https://github.com/NixOS/nixpkgs/archive/4ecab3273592f27479a583fb6d975d4aba3486fe.tar.gz
TMPDIR=$(mktemp -d)
OUTDIR="./.certs"
ca="ca"
ca_csr="${TMPDIR}/ca.json"
ca_config="${TMPDIR}/ca-config.json"
key_algo=rsa
key_size=2048
cert_expire=43800 # = 5 years * 365 days * 24 hours
C="US"
L="PA"
O="Equinix Metal Development"
OU="Nautilus"
ST="Philadelphia"
cat <<-EOFCACONFIG > ${ca_config}
{
"signing": {
"default": {
"expiry": "${cert_expire}h"
},
"profiles": {
"server": {
"expiry": "${cert_expire}h",
"usages": [
"signing",
"key encipherment",
"server auth"
]
},
"client": {
"expiry": "${cert_expire}h",
"usages": [
"signing",
"key encipherment",
"client auth"
]
},
"client-server": {
"expiry": "${cert_expire}h",
"usages": [
"signing",
"key encipherment",
"server auth",
"client auth"
]
}
}
}
}
EOFCACONFIG
function generate_ca {
echo "==================== generating self-signed CA key pair"
cat <<-EOFCACSR > ${ca_csr}
{
"CN": "Nautilus Local CA",
"key": {
"algo": "${key_algo}",
"size": ${key_size}
},
"names": [
{
"C": "${C}",
"L": "${L}",
"O": "${O}",
"ST": "${ST}",
"OU": "${OU}"
}
]
}
EOFCACSR
cfssl gencert -initca "${ca_csr}" | cfssljson -bare ${ca}
mv "${ca}.pem" "${OUTDIR}/${ca}.pem"
mv "${ca}-key.pem" "${OUTDIR}/${ca}-key.pem"
}
function generate_server_certificate {
echo "=================== generating server certificate"
server_csr="${TMPDIR}/server-csr.json"
cat <<EOF > ${server_csr}
{
"CN": "Nautilus Hub",
"hosts": [ "localhost", "hub.example.net" ],
"key": {
"algo": "${key_algo}",
"size": ${key_size}
}
}
EOF
cfssl gencert -ca="${OUTDIR}/${ca}.pem" -ca-key="${OUTDIR}/${ca}-key.pem" -config="${ca_config}" -profile=client-server ${server_csr} | cfssljson -bare server
}
function generate_client_certificate {
echo "================== generating client certificate"
client_csr="${TMPDIR}/client-csr.json"
cat <<EOF > ${client_csr}
{
"CN": "Nautilus Spoke 1",
"hosts": [ "localhost", "spoke1.example.net" ],
"key": {
"algo": "${key_algo}",
"size": ${key_size}
}
}
EOF
cfssl gencert -ca="${OUTDIR}/${ca}.pem" -ca-key="${OUTDIR}/${ca}-key.pem" -config="${ca_config}" -profile=client ${client_csr} | cfssljson -bare client
}
function generate_admin_certificate {
echo "================= generating admin certificate"
admin_csr="${TMPDIR}/admin-csr.json"
cat <<EOF > ${admin_csr}
{
"CN": "Nautilus Admin - Adam",
"key": {
"algo": "${key_algo}",
"size": ${key_size}
},
"names": [
{
"C": "${C}",
"L": "${L}",
"O": "${O}",
"ST": "${ST}",
"OU": "Nautilus Admins"
}
]
}
EOF
cfssl gencert -ca="${OUTDIR}/${ca}.pem" -ca-key="${OUTDIR}/${ca}-key.pem" -config="${ca_config}" -profile=client ${admin_csr} | cfssljson -bare admin
}
function move_certs {
echo "================ copying certificates to ${OUTDIR}"
for c in server client admin; do
[[ -f "${c}-key.pem" ]] && mv "${c}-key.pem" "${OUTDIR}/${c}-key.pem" || echo "${c}-key.pem not regenerated"
[[ -f "${c}.pem" ]] && mv "${c}.pem" "${OUTDIR}/${c}.pem" || echo "${c}.pem not regenerated"
done
}
function main {
mkdir -p "${OUTDIR}"
[[ -f "${OUTDIR}/ca.pem" ]] || generate_ca
[[ -f "${OUTDIR}/server.pem" ]] || generate_server_certificate
[[ -f "${OUTDIR}/client.pem" ]] || generate_client_certificate
[[ -f "${OUTDIR}/admin.pem" ]] || generate_admin_certificate
move_certs
rm {ca,server,admin,client}.csr 2>/dev/null
chmod 600 ${OUTDIR}/*-key.pem
}
main

View File

@@ -1,74 +1,106 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/json" "crypto/tls"
"crypto/x509"
"fmt" "fmt"
"io/ioutil" "io"
"log"
"net/http" "net/http"
"os" "os"
"golang.org/x/oauth2" "go.fixergrid.net/servicedemon/appconfig"
"go.fixergrid.net/servicedemon/pubsub"
"go.fixergrid.net/servicedemon/pkg/discord" "go.fixergrid.net/servicedemon/registrar"
) )
func main() { func main() {
fmt.Println("Starting .... the >HUB<") ctx, cancel := context.WithCancel(context.Background())
defer cancel()
token, isSet := os.LookupEnv("DISCORD_BOT_TOKEN") logger := log.New(os.Stdout, "main: ", log.LstdFlags|log.Lshortfile)
if !isSet { logger.Println("Starting .... the >HUB<")
fmt.Println("please set the environment variable 'DISCORD_BOT_TOKEN'")
return
}
tokensrc := oauth2.StaticTokenSource(&oauth2.Token{ pubsub := pubsub.New()
AccessToken: token, repo := registrar.NewRepo()
TokenType: "Bot",
})
ctx := context.Background() r := registrar.NewRegistrar(
pubsub,
repo,
)
client := discord. al := registrar.NewApprovalListener(
NewClient(ctx, tokensrc). pubsub,
WithDefaultChannel("1125162127133523978") nil,
repo,
)
go al.Run(ctx)
resp, err := client.Get("https://discord.com/api/v10/channels/1125162127133523978") appConfig := appconfig.NewProvider()
mux := http.NewServeMux()
logger.Println("Registering endpoints...")
mux.HandleFunc("/register", postjson(r.HandleRegistration))
logger.Println("POST /register")
mux.Handle("/approvals/", http.StripPrefix("/approvals/", wrapHandleFunc(postjson(r.HandleApproval))))
logger.Println("POST /approvals/:id")
mux.Handle("/application/", http.StripPrefix("/application", appConfig.Handler()))
logger.Println("GET /application/config/authz")
server, err := newServer()
if err != nil { if err != nil {
fmt.Printf("there was an error making the request: %v\n", err) logger.Fatal(err)
return
} }
out, err := ioutil.ReadAll(resp.Body) server.Handler = mux
if err != nil { log.Println(server.ListenAndServeTLS("", ""))
fmt.Printf("failed to read response body: %v\n", err) }
return
} func newServer() (*http.Server, error) {
// "./certs/combined.pem", "./certs/server-key.pem"
fmt.Printf("Got response:\n%s\n", out) requiredVars := map[string]string{
"HUB_CA_CERT_FILE": "",
messageContent := map[string]interface{}{ "HUB_SERVER_CERT_FILE": "",
"content": "Hello, world!", "HUB_SERVER_KEY_FILE": "",
} }
b, err := json.Marshal(messageContent) for k := range requiredVars {
if err != nil { val, isSet := os.LookupEnv(k)
fmt.Printf("failed to marshal message: %v\n", err) if !isSet {
return return nil, fmt.Errorf("hub: required environment variable is unset: %s", k)
} }
requiredVars[k] = val
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) certFile, err := os.Open(requiredVars["HUB_CA_CERT_FILE"])
return if err != nil {
} return nil, fmt.Errorf("hub: failed to open ca.pem: %w", err)
}
out, err = ioutil.ReadAll(r2.Body)
if err != nil { caCert, err := io.ReadAll(certFile)
fmt.Printf("failed to read response body on send message: %v\n", err) if err != nil {
return return nil, fmt.Errorf("hub: failed to read in ca: %w", err)
} }
fmt.Printf("status: %v - res: %v\n", r2.Status, out) pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caCert)
serverCert, err := tls.LoadX509KeyPair(requiredVars["HUB_SERVER_CERT_FILE"], requiredVars["HUB_SERVER_KEY_FILE"])
if err != nil {
return nil, fmt.Errorf("hub: failed to load server certs: %w", err)
}
server := &http.Server{
Addr: ":3001",
TLSConfig: &tls.Config{
ClientCAs: pool,
ClientAuth: tls.RequireAndVerifyClientCert,
Certificates: []tls.Certificate{serverCert},
},
}
return server, nil
} }

46
cmd/hub/middlewares.go Normal file
View File

@@ -0,0 +1,46 @@
package main
import (
"fmt"
"net/http"
)
func postjson(f http.HandlerFunc) http.HandlerFunc {
return contentJSON(methodPost(f))
}
func methodPost(f http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
fmt.Fprintf(w, `{"errors": ["method not allowed"]}`)
return
}
f(w, req)
}
}
func contentJSON(f http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
headers := w.Header()
headers.Set("content-type", "application/json")
f(w, req)
}
}
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)
}

131
cmd/spoke-agent/main.go Normal file
View File

@@ -0,0 +1,131 @@
package main
import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"time"
)
var HUB_BASE_URL string = ""
func main() {
logger := log.New(os.Stdout, "main: ", log.LstdFlags|log.Lshortfile)
client, err := HubClient()
if err != nil {
logger.Fatal(err)
}
registered := false
for !registered {
state := getCurrentState(client, logger)
switch state {
case "pending_approval":
logger.Println("waiting for approval")
case "registered":
registered = true
logger.Println("application registered!")
default:
logger.Println("retrying")
}
time.Sleep(3 * time.Second)
}
}
func HubClient() (*http.Client, error) {
requiredVars := map[string]string{
"SPOKE_AGENT_CA_CERT_FILE": "",
"SPOKE_AGENT_CERT_FILE": "",
"SPOKE_AGENT_KEY_FILE": "",
"HUB_SERVER_URL": "",
}
for k := range requiredVars {
val, isSet := os.LookupEnv(k)
if !isSet {
return nil, fmt.Errorf("spoke agent: required environment variables is unset: %s", k)
}
requiredVars[k] = val
}
caFile, err := os.Open(requiredVars["SPOKE_AGENT_CA_CERT_FILE"])
if err != nil {
return nil, fmt.Errorf("failed to open ca cert: %w", err)
}
caCert, err := io.ReadAll(caFile)
if err != nil {
return nil, fmt.Errorf("failed to read the ca cert: %w", err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caCert)
certPath := requiredVars["SPOKE_AGENT_CERT_FILE"]
keyPath := requiredVars["SPOKE_AGENT_KEY_FILE"]
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, fmt.Errorf("failed to load keypair: %w", err)
}
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: pool,
Certificates: []tls.Certificate{cert},
},
},
}
HUB_BASE_URL = requiredVars["HUB_SERVER_URL"]
return client, nil
}
func getCurrentState(client *http.Client, logger *log.Logger) string {
reqURL, err := url.JoinPath(HUB_BASE_URL, "/register")
if err != nil {
logger.Fatalf("failed to setup register URL: %v", err)
}
resp, err := client.Post(reqURL, "application/json", nil)
if err != nil {
logger.Fatalf("registration failed: %v", err)
}
if resp.StatusCode > 399 {
logger.Printf("ERROR: getCurrentState: failed to get status [%d]", resp.StatusCode)
return ""
}
out := map[string]string{}
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("ERROR: getCurrentState: parsing response body: %v", err)
return ""
}
err = json.Unmarshal(body, &out)
if err != nil {
log.Printf("ERROR: getCurrentState: failed to unmarshal response: %s '%s'", err, body)
return ""
}
switch resp.StatusCode {
case http.StatusCreated:
logger.Printf("successfully started application registration")
return "pending_approval"
case http.StatusOK:
return out["status"]
default:
return ""
}
}

9
go.mod
View File

@@ -3,16 +3,19 @@ module go.fixergrid.net/servicedemon
go 1.20 go 1.20
require ( 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 golang.org/x/oauth2 v0.9.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/protobuf v1.5.2 // 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 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/net v0.11.0 // indirect
golang.org/x/sys v0.9.0 // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.0 // 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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= 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.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.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 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/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.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/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 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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-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-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 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= 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 h1:BPpt2kU7oMRq3kCHAA1tbSEshXRw1LpG2ztgDwrzuAs=
golang.org/x/oauth2 v0.9.0/go.mod h1:qYgFZaFiu6Wg24azG8bdV52QJXJGbZzIIsRCdVKzbLw= 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-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.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.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/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= 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= 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.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 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=

6
manifests/hub-cacrt.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: Secret
metadata:
name: hub-ca-crt
data:
ca.crt: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUZWRENDQkR5Z0F3SUJBZ0lSQU8xZFc4bHQrOTlOUHMxcVNZM1JzOGN3RFFZSktvWklodmNOQVFFTEJRQXcKY1RFTE1Ba0dBMVVFQmhNQ1ZWTXhNekF4QmdOVkJBb1RLaWhUVkVGSFNVNUhLU0JKYm5SbGNtNWxkQ0JUWldOMQpjbWwwZVNCU1pYTmxZWEpqYUNCSGNtOTFjREV0TUNzR0ExVUVBeE1rS0ZOVVFVZEpUa2NwSUVSdlkzUnZjbVZrCklFUjFjbWxoYmlCU2IyOTBJRU5CSUZnek1CNFhEVEl4TURFeU1ERTVNVFF3TTFvWERUSTBNRGt6TURFNE1UUXcKTTFvd1pqRUxNQWtHQTFVRUJoTUNWVk14TXpBeEJnTlZCQW9US2loVFZFRkhTVTVIS1NCSmJuUmxjbTVsZENCVApaV04xY21sMGVTQlNaWE5sWVhKamFDQkhjbTkxY0RFaU1DQUdBMVVFQXhNWktGTlVRVWRKVGtjcElGQnlaWFJsCmJtUWdVR1ZoY2lCWU1UQ0NBaUl3RFFZSktvWklodmNOQVFFQkJRQURnZ0lQQURDQ0Fnb0NnZ0lCQUxiYWdFZEQKVGExUWdHQldTWWt5TWhzY1pYRU5PQmFWUlRNWDFoY2VKRU5nc0wwTWE0OUQzTWlsSTRLUzM4bXRrbWRGNmNQVwpuTCsrZmdlaFQwRmJSSFpnak9FcjhVQU40akg2b21qcmJURCsrVlpuZVRzTVZhR2FtUW1EZEZsNWcxZ1lhaWdrCmtteDhPaUNPNjhhNFFYZzR3U3luNmlEaXBLUDh1dHNFK3gxRTI4U0E3NUhPWXFwZHJrNEhHeHVVTHZscjAzd1oKR1RJZi9vUnQyL2MrZFltRG9hSmhnZStHT3JMQUVRQnlPNys4K3Z6T3dwTkFQRXg2TFcrY3JFRVo3ZUJYaWg2VgpQMTlzVEd5M3lmcUs1dFB0VGRYWENPUU1LQXArZ0NqL1ZCeWhtSXIrMGlOREM1NDBndHZWMzAzV3BjYndua2tMCllDMEZ0MmNZVXlIdGtzdE9mUmNSTytLMmNab3pvU3dWUHlCOC9KOVJwY1JLM2pnblg5bHVqZndBL3BBYlAwSjIKVVBRRnhtV0ZSUW5GamFxNnJrcWJORUJnTHkra0ZMMU5Fc1JidkZiS3JSaTViWXkybE5tczJOSlBadmROUWJULwoyZEJaS21KcXhIa3hDdU9RRmpoSlFOZU8rTmptMVoxaUFUUy8zcnRzMnlabHFYS3N4UVV6TjZ2TmJEOEtuWFJNCkVlT1hVWXZiVjRscWZDZjhtUzE0V0ViU2lNeTg3R0I1Uzl1Y1NWMVhVcmxURzVVR2NNU1pPQmNFVXBpc1JQRW0KUVdVT1RXSW9EUTVGT2lhL0dJK0tpNTIzcjJydUVtYm1HMzdFQlNCWGR4SWRuZHFyankrUVZBbUNlYnlEeDllVgpFR09JcG4yNmJXNUxLZXJ1bUp4YS9DRkJhS2k0YlJ2bWRKUkxBZ01CQUFHamdmRXdnZTR3RGdZRFZSMFBBUUgvCkJBUURBZ0VHTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3SFFZRFZSME9CQllFRkxYelpmTCtzQXFTSC9zOGZmTkUKb0t4akpjTVVNQjhHQTFVZEl3UVlNQmFBRkFoWDJvbkhvbE41REUvZDRKQ1BkTHJpSjNORU1EZ0dDQ3NHQVFVRgpCd0VCQkN3d0tqQW9CZ2dyQmdFRkJRY3dBb1ljYUhSMGNEb3ZMM04wWnkxa2MzUXpMbWt1YkdWdVkzSXViM0puCkx6QXRCZ05WSFI4RUpqQWtNQ0tnSUtBZWhoeG9kSFJ3T2k4dmMzUm5MV1J6ZERNdVl5NXNaVzVqY2k1dmNtY3YKTUNJR0ExVWRJQVFiTUJrd0NBWUdaNEVNQVFJQk1BMEdDeXNHQVFRQmd0OFRBUUVCTUEwR0NTcUdTSWIzRFFFQgpDd1VBQTRJQkFRQjd0UjhCMGVJUVNTNk1oUDVrdXZHdGgrZE4wMkRzSWhyMHlKdGsyZWhJY1BJcVN4UlJtSEdsCjR1MmMzUWx2RXBlUkRwMnc3ZVFkUlRsSS9Xbk5oWTRKT29mcE1mMnp3QUJnQld0QXUwVm9vUWNaWlRwUXJ1aWcKRi96NnhZa0JrM1VIa2plcXh6TU4zZDFFcUd1c3hKb3FnZFRvdVo1WDVRVFRJZWU5blEzTEVoV25SU1hEeDdZMAp0dFIxQkdmY2RxSG9wTzRJQnFBaGJrS1JqRjV6ajdPRDhjRzM1b215d1ViWnRPSm5mdGlJMG5GY1JheGJYbzB2Cm9EZkxEMFM2K0FDMlIzdEtwcWprTlg2LzkxaHJSRmdsVWFreU1jWlUveGxlcWJ2NitMcjNZRDhQc0JUdWI2bEkKb1oybFMzOGZMMThBb240NThmYmMwQlBIdGVuZmhLajUKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo="

34
manifests/hub-cert.yaml Normal file
View File

@@ -0,0 +1,34 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: hub-dev-fixergrid-net-stg
namespace: hub
spec:
# Secret names are always required.
secretName: hub-dev-stg-cert-tls
duration: 2160h # 90d
renewBefore: 360h # 15d
subject:
organizations:
- Equinix Metal
# The use of the common name field has been deprecated since 2000 and is
# discouraged from being used.
commonName: hub.dev.fixergrid.net
isCA: false
privateKey:
algorithm: RSA
encoding: PKCS1
size: 2048
usages:
- server auth
- client auth
# At least one of a DNS Name, URI, or IP address is required.
dnsNames:
- hub.dev.fixergrid.net
# Issuer references are always required.
issuerRef:
name: letsencrypt-staging
# We can reference ClusterIssuers by changing the kind here.
# The default value is Issuer (i.e. a locally namespaced Issuer)
kind: ClusterIssuer

71
manifests/hub.yaml Normal file
View File

@@ -0,0 +1,71 @@
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: hub
name: hub
namespace: hub
spec:
replicas: 1
selector:
matchLabels:
app: hub
template:
metadata:
labels:
app: hub
spec:
volumes:
- name: server-certs
projected:
sources:
- secret:
name: hub-dev-stg-cert-tls
- secret:
name: hub-ca-crt
containers:
- image: amohd/servicedemon:v2
name: servicedemon
command: ["/hub"]
env:
- name: HUB_CA_CERT_FILE
value: /etc/hub/certs/ca.crt
- name: HUB_SERVER_CERT_FILE
value: /etc/hub/certs/tls.crt
- name: HUB_SERVER_KEY_FILE
value: /etc/hub/certs/tls.key
volumeMounts:
- name: server-certs
mountPath: /etc/hub/certs/
---
apiVersion: v1
kind: Service
metadata:
name: hub-svc
namespace: hub
spec:
type: ClusterIP
selector:
app: hub
ports:
- port: 443
targetPort: 3001
protocol: "TCP"
---
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRouteTCP
metadata:
namespace: hub
name: hub-dev-fixergrid-net
spec:
entryPoints:
- websecure
tls:
passthrough: true
routes:
- match: HostSNI(`hub.dev.fixergrid.net`)
priority: 1
services:
- name: hub-svc
port: 443
weight: 1

19
manifests/issuer.yaml Normal file
View File

@@ -0,0 +1,19 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
# You must replace this email address with your own.
# Let's Encrypt will use this to contact you about expiring
# certificates, and issues related to your account.
email: adam@fixergrid.net
server: https://acme-staging-v02.api.letsencrypt.org/directory
privateKeySecretRef:
# Secret resource that will be used to store the account's private key.
name: dev-fixergrid-net-issuer-account-key
# Add a single challenge solver, HTTP01 using nginx
solvers:
- http01:
ingress:
ingressClassName: traefik

34
manifests/my-app-crt.yaml Normal file
View File

@@ -0,0 +1,34 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: app-dev-fixergrid-net-stg
namespace: app1
spec:
# Secret names are always required.
secretName: app1-dev-stg-cert-tls
duration: 2160h # 90d
renewBefore: 360h # 15d
subject:
organizations:
- Equinix Metal
# The use of the common name field has been deprecated since 2000 and is
# discouraged from being used.
commonName: app1.dev.fixergrid.net
isCA: false
privateKey:
algorithm: RSA
encoding: PKCS1
size: 2048
usages:
- server auth
- client auth
# At least one of a DNS Name, URI, or IP address is required.
dnsNames:
- app1.dev.fixergrid.net
# Issuer references are always required.
issuerRef:
name: letsencrypt-staging
# We can reference ClusterIssuers by changing the kind here.
# The default value is Issuer (i.e. a locally namespaced Issuer)
kind: ClusterIssuer

42
manifests/my-app.yaml Normal file
View File

@@ -0,0 +1,42 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: app1
name: app1
spec:
replicas: 1
selector:
matchLabels:
app: app1
template:
metadata:
labels:
app: app1
spec:
volumes:
- name: server-certs
projected:
sources:
- secret:
name: app1-dev-stg-cert-tls
- secret:
name: hub-ca-crt
containers:
- image: amohd/servicedemon:v2
name: servicedemon
command: ["/spoke-agent"]
env:
- name: SPOKE_AGENT_CA_CERT_FILE
value: /etc/spoke-agent/certs/ca.crt
- name: SPOKE_AGENT_CERT_FILE
value: /etc/spoke-agent/certs/tls.crt
- name: SPOKE_AGENT_KEY_FILE
value: /etc/spoke-agent/certs/tls.key
- name: HUB_SERVER_URL
value: https://hub.dev.fixergrid.net
volumeMounts:
- name: server-certs
mountPath: /etc/spoke-agent/certs/

View File

@@ -1,39 +0,0 @@
package discord
import (
"context"
"io"
"net/http"
"golang.org/x/oauth2"
)
type ChannelInfo struct{}
type DiscordClient struct {
client *http.Client
defaultChannel string
baseUrl string
apiVersion string
}
func NewClient(ctx context.Context, tokenSrc oauth2.TokenSource) DiscordClient {
return DiscordClient{
client: oauth2.NewClient(ctx, tokenSrc),
baseUrl: "https://discord.com",
apiVersion: "v10",
}
}
func (dc DiscordClient) WithDefaultChannel(channelID string) DiscordClient {
dc.defaultChannel = channelID
return dc
}
func (dc DiscordClient) GetChannel() ChannelInfo {
return ChannelInfo{}
}
func (dc DiscordClient) doRequest(path string, method string, body io.Reader) (*http.Response, error) {
return dc.client.Do(method, path, body)
}

51
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)
}
}
}
}

91
registrar/approval.go Normal file
View File

@@ -0,0 +1,91 @@
package registrar
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"github.com/google/uuid"
svc "go.fixergrid.net/servicedemon/registrar/internal/services"
)
const APPROVAL_TOPIC = "net.fixergrid.events.app.approved"
type ApprovalListener struct {
svc.ApprovalRequester
svc.PubSub
svc.AppRepo
log ilogger
}
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, repo svc.AppRepo, options ...option) ApprovalListener {
out := ApprovalListener{
PubSub: ps,
ApprovalRequester: requester,
AppRepo: repo,
log: log.New(os.Stderr, "servicedemon/registrar - ", log.LstdFlags|log.Lshortfile),
}
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
}

114
registrar/endpoints.go Normal file
View File

@@ -0,0 +1,114 @@
package registrar
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"github.com/google/uuid"
svc "go.fixergrid.net/servicedemon/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 ilogger
}
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,
log: log.New(os.Stderr, "servicedemon/registrar - ", log.LstdFlags|log.Lshortfile),
}
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]
app, err := r.repo.GetApp(name)
switch err {
case svc.ErrApplicationNotFound:
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"}`)
case nil:
resp.WriteHeader(http.StatusOK)
fmt.Fprintf(resp, `{"status": "%s"}`, app.State)
default:
resp.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(resp, `{"errors": ["%s"]}`, err)
}
}
func (r Registrar) HandleApproval(resp http.ResponseWriter, req *http.Request) {
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
}

139
registrar/endpoints_test.go Normal file
View File

@@ -0,0 +1,139 @@
package registrar
import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.fixergrid.net/servicedemon/pubsub"
)
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 := NewRepo()
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 := NewRepo()
appID := repo.StartAppRegistration("dev-app-1.delivery.engineering")
repo.ApproveApp(appID)
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 TestHandleRegisterNotYetApproved(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 := NewRepo()
repo.StartAppRegistration("dev-app-1.delivery.engineering")
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": "pending_approval"}`), 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])
}
}
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
}

View File

@@ -0,0 +1,66 @@
package persistence
import (
"github.com/google/uuid"
svc "go.fixergrid.net/servicedemon/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) GetApp(name string) (svc.Application, error) {
for _, app := range repo.apps {
if name == app.Name {
return *app, nil
}
}
return svc.Application{}, svc.ErrApplicationNotFound
}
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 {
newApp := &svc.Application{
ID: uuid.New(),
Name: name,
State: "pending_approval",
}
repo.apps = append(repo.apps, newApp)
return newApp.ID
}
func (repo *FakeRepo) ApproveApp(id uuid.UUID) (svc.Application, error) {
for _, application := range repo.apps {
if application.ID == id {
application.State = "registered"
return *application, nil
}
}
return svc.Application{}, svc.ErrApplicationNotFound
}

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,27 @@
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
GetApp(name string) (Application, error)
StartAppRegistration(name string) uuid.UUID
ApproveApp(id uuid.UUID) (Application, error)
}

33
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
registrar/repo.go Normal file
View File

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