From 9bbf8a96aaebfebed47de471094082e84a81301a Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sat, 3 Oct 2026 04:39:14 +0200 Subject: [PATCH] feat: deploy --- Makefile | 55 ++++- README.md | 86 +++++--- backend/internal/config/config.go | 15 +- backend/internal/web/handler/sso.go | 32 ++- backend/internal/web/router/router_test.go | 46 +++++ deploy/helm/go-guess/Chart.yaml | 6 +- deploy/helm/go-guess/templates/NOTES.txt | 59 ++++-- deploy/helm/go-guess/templates/_helpers.tpl | 69 +++++++ .../go-guess/templates/app-deployment.yaml | 105 ---------- .../helm/go-guess/templates/app-service.yaml | 19 -- deploy/helm/go-guess/templates/ingress.yaml | 25 ++- deploy/helm/go-guess/templates/secret.yaml | 11 + .../templates/tests/test-connection.yaml | 9 +- deploy/helm/go-guess/values-development.yaml | 54 +++-- deploy/helm/go-guess/values-production.yaml | 54 +++-- deploy/helm/go-guess/values.yaml | 189 ++++++++++++------ 16 files changed, 542 insertions(+), 292 deletions(-) delete mode 100644 deploy/helm/go-guess/templates/app-deployment.yaml delete mode 100644 deploy/helm/go-guess/templates/app-service.yaml diff --git a/Makefile b/Makefile index 7dc4527..fb92e7d 100644 --- a/Makefile +++ b/Makefile @@ -7,10 +7,15 @@ BDD_COMPOSE := docker compose -f docker-compose.bdd.yml HELM ?= helm HELM_CHART := deploy/helm/go-guess HELM_RELEASE ?= go-guess -HELM_NAMESPACE ?= go-guess -KUBECONFIG ?= $(HOME)/.config/kubectl/rancher.urpi.local.yaml +HELM_NAMESPACE ?= go-insane +KUBECONFIG ?= $(HOME)/.kube/config +HELM_KUBECTL := KUBECONFIG="$(KUBECONFIG)" $(HELM) +GO_LOOSE_SET ?= --set-string api.goLoose.tenants.nmbs.clientID="$${GO_LOOSE_NMBS_CLIENT_ID:-}" \ + --set-string api.goLoose.tenants.nmbs.clientSecret="$${GO_LOOSE_NMBS_CLIENT_SECRET:-}" \ + --set-string api.goLoose.tenants.ypto.clientID="$${GO_LOOSE_YPTO_CLIENT_ID:-}" \ + --set-string api.goLoose.tenants.ypto.clientSecret="$${GO_LOOSE_YPTO_CLIENT_SECRET:-}" -.PHONY: help dev db-up db-down migrate migrate-down seed backend frontend frontend-install test test-backend test-frontend test-integration test-bdd test-bdd-down test-env test-env-down lint build rancher-storage helm-lint helm-deploy-development helm-deploy-production helm-down-production helm-down-development +.PHONY: help dev db-up db-down migrate migrate-down seed backend frontend frontend-install test test-backend test-frontend test-integration test-bdd test-bdd-down test-env test-env-down lint build rancher-storage ingress-nginx-install cert-manager-install cluster-prepare helm-lint helm-deploy-development helm-deploy-production helm-down-production helm-down-development help: @echo "make dev builds the demo docker image" @@ -33,6 +38,9 @@ help: @echo "make lint runs lint on the frontend" @echo "make build builds the entire project" @echo "make rancher-storage installs the persistent local-path StorageClass" + @echo "make ingress-nginx-install installs the ingress-nginx controller serving 80/443" + @echo "make cert-manager-install installs cert-manager for chart-issued TLS certificates" + @echo "make cluster-prepare runs every cluster prerequisite in order" @echo "make helm-lint lints development and production Helm configurations" @echo "make helm-deploy-development deploys the development Helm release" @echo "make helm-deploy-production deploys the production Helm release" @@ -104,6 +112,27 @@ build: rancher-storage: KUBECONFIG="$(KUBECONFIG)" kubectl apply -f deploy/rancher/local-path.yaml +ingress-nginx-install: + $(HELM_KUBECTL) repo add ingress-nginx https://kubernetes.github.io/ingress-nginx --force-update + $(HELM_KUBECTL) repo update + $(HELM_KUBECTL) upgrade --install ingress-nginx ingress-nginx/ingress-nginx \ + --namespace ingress-nginx --create-namespace \ + --set controller.service.type=NodePort \ + --set controller.hostPort.enabled=true \ + --set controller.kind=DaemonSet \ + --set controller.admissionWebhooks.enabled=false \ + --wait --timeout 10m + +cert-manager-install: + $(HELM_KUBECTL) repo add jetstack https://charts.jetstack.io --force-update + $(HELM_KUBECTL) repo update + $(HELM_KUBECTL) upgrade --install cert-manager jetstack/cert-manager \ + --namespace cert-manager --create-namespace \ + --set crds.enabled=true \ + --wait --timeout 10m + +cluster-prepare: rancher-storage ingress-nginx-install cert-manager-install + helm-lint: KUBECONFIG="$(KUBECONFIG)" $(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-development.yaml KUBECONFIG="$(KUBECONFIG)" $(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-production.yaml @@ -113,17 +142,25 @@ helm-lint: -f $(HELM_CHART)/values-production.yaml >/dev/null helm-deploy-development: - KUBECONFIG="$(KUBECONFIG)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) --namespace $(HELM_NAMESPACE)-development --create-namespace -f $(HELM_CHART)/values-development.yaml --rollback-on-failure --wait --timeout 10m + $(HELM_KUBECTL) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \ + --namespace $(HELM_NAMESPACE)-development --create-namespace \ + -f $(HELM_CHART)/values-development.yaml \ + --set secrets.postgresqlPassword="go_guess" \ + --set secrets.rabbitmqPassword="guest" \ + $(GO_LOOSE_SET) \ + --rollback-on-failure --wait --timeout 10m helm-deploy-production: - KUBECONFIG="$(KUBECONFIG)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \ + $(HELM_KUBECTL) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \ --namespace $(HELM_NAMESPACE)-production --create-namespace \ - -f $(HELM_CHART)/values-production.yaml --wait --timeout 10m\ + -f $(HELM_CHART)/values-production.yaml \ --set secrets.postgresqlPassword="go_guess" \ - --set secrets.rabbitmqPassword="guest" + --set secrets.rabbitmqPassword="guest" \ + $(GO_LOOSE_SET) \ + --wait --timeout 10m helm-down-production: - KUBECONFIG="$(KUBECONFIG)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-production + $(HELM_KUBECTL) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-production helm-down-development: - KUBECONFIG="$(KUBECONFIG)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-development + $(HELM_KUBECTL) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-development diff --git a/README.md b/README.md index a740172..a17596d 100644 --- a/README.md +++ b/README.md @@ -156,36 +156,56 @@ make test-env-down ## Production image -Build the complete application as a single image: +Two images are published to the private registry: `go-guess-api:0.0.1` for the Go +service and `go-guess-web:0.0.1` for the React front end behind nginx. ```bash -docker build -t go-guess . +docker build -t batty1039.startdedicated.net:5000/go-guess-api:0.0.1 ./backend +docker build -t batty1039.startdedicated.net:5000/go-guess-web:0.0.1 ./frontend ``` -The image serves the frontend and API on port `8080`. It applies database -migrations and shared production fixtures before startup. PostgreSQL, RabbitMQ, -and these environment variables must be provided: +The API image listens on `8080` and applies database migrations and shared +fixtures before serving. The web image listens on `80`, serves the built assets, +and proxies `/api/` to `http://api:8080`, so both containers must share a +network with the API reachable under the DNS name `api`. PostgreSQL, RabbitMQ, +and these environment variables must be provided to the API: ```bash docker run --rm -p 8080:8080 \ -e DATABASE_URL='postgres://user:password@postgres:5432/go_guess?sslmode=disable' \ -e RABBITMQ_URL='amqp://user:password@rabbitmq:5672/' \ -e JWT_SECRET='replace-with-at-least-32-characters' \ - -e FRONTEND_URL='https://go-guess.example.com' \ - go-guess + -e FRONTEND_URL='https://nmbs.guess.local' \ + batty1039.startdedicated.net:5000/go-guess-api:0.0.1 ``` +The `Dockerfile` at the repository root still builds one combined image that +serves both halves on port `8080`. It is convenient for local runs and browser +BDD tests, while Kubernetes deploys the two images separately. + Pushes to `master` publish `go-guess:latest` and `go-guess:` to the private registry configured in `.github/workflows/publish-image.yml`. The repository must provide `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` secrets. ## Rancher and Helm -The chart under `deploy/helm/go-guess` deploys the application, PostgreSQL, and -RabbitMQ. It works with any Kubernetes cluster managed by Rancher; Rancher's free -edition does not restrict Helm or GitHub Actions deployments. +The chart under `deploy/helm/go-guess` deploys the API image, the web image, and +their PostgreSQL and RabbitMQ. Every tenant gets its own HTTPS host at +`https://.guess.local`. The chart values are documented in +[deploy/helm/go-guess/README.md](deploy/helm/go-guess/README.md). -Validate or deploy one of the environment profiles: +One web deployment serves every tenant host because the API resolves the tenant +from the request `Host` header, so a new tenant is one entry in `tenants`. + +Install the cluster prerequisites once per cluster. Storage for persistent +volumes, an ingress-nginx controller for the `nginx` ingress class, and +cert-manager for the chart-issued certificate: + +```bash +make cluster-prepare +``` + +Then validate or deploy one of the environment profiles: ```bash make helm-lint @@ -193,25 +213,41 @@ make helm-deploy-development make helm-deploy-production ``` -For the repository's single-node Docker Rancher setup, run -`make rancher-storage` once before the first production deployment. This -installs the default persistent StorageClass documented in -`rancher-install.txt`. +Development uses `latest` images, development fixtures, and ephemeral database +and broker storage in namespace `go-insane-development`. Production uses the +released `0.0.1` tags, production fixtures, and persistent volumes in namespace +`go-insane-production`. -Development uses demo fixtures, ephemeral database and message-broker storage, -and NodePort `30080`. Production uses production fixtures, persistent volumes, -generated secrets that are preserved across upgrades, and larger resource -limits. It exposes the application at NodePort `31374` for the dedicated-server -Nginx proxy while PostgreSQL and RabbitMQ remain cluster-internal. The public -URL is `https://goguess.urpi.be`; one.com DNS, Nginx, Certbot, firewall, and -verification instructions are in `deploy/nginx/README.md`. +Certificates are self-signed and issued by the chart, so every browser or client +that opens a tenant host needs the CA from the release: + +```bash +kubectl -n go-insane-production get secret go-guess-web-ca \ + -o jsonpath='{.data.ca\.crt}' | base64 -d > go-guess-ca.crt +``` + +Point each tenant host at the ingress controller address, then open +`https://nmbs.guess.local` and `https://ypto.guess.local`. Browser sign-in +through Go Loose needs the per-tenant client credentials; pass them from the +environment to `make helm-deploy-production`: + +```bash +export GO_LOOSE_NMBS_CLIENT_ID=... GO_LOOSE_NMBS_CLIENT_SECRET=... +export GO_LOOSE_YPTO_CLIENT_ID=... GO_LOOSE_YPTO_CLIENT_SECRET=... +``` + +Without credentials for a tenant, that host keeps password login. The cluster +served by `~/.kube/config` is managed by Rancher at `rancher.urpi.be`, which is +the management interface and not the deployment target. One.com DNS, Nginx, +Certbot, and firewall instructions for the single-node dedicated server are in +`deploy/nginx/README.md`. To use external managed services, disable the bundled StatefulSets and provide full connection URLs: ```bash helm upgrade --install go-guess deploy/helm/go-guess \ - --namespace go-guess-production --create-namespace \ + --namespace go-insane-production --create-namespace \ -f deploy/helm/go-guess/values-production.yaml \ --set postgresql.enabled=false \ --set rabbitmq.enabled=false \ @@ -229,8 +265,8 @@ workflow succeeds. Create GitHub environments named `development` and - Secrets `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and `GO_GUESS_JWT_SECRET`. - Variable `RANCHER_NAMESPACE` for the target namespace. -- Optional variable `GO_GUESS_FRONTEND_URL` when overriding an environment's - configured public URL. +- Optional secrets `GO_LOOSE_NMBS_CLIENT_ID`, `GO_LOOSE_NMBS_CLIENT_SECRET`, + `GO_LOOSE_YPTO_CLIENT_ID`, and `GO_LOOSE_YPTO_CLIENT_SECRET`. Because the configured image registry uses HTTP, every Rancher cluster node must trust `batty1039.startdedicated.net:5000` as an insecure registry. Use HTTPS for diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e68a2b1..6f408be 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strconv" + "strings" "time" ) @@ -30,6 +31,13 @@ type GoLooseTenant struct { ClientSecret string } +// GoLooseCredentialEnv returns the environment variable names holding the Go +// Loose client credentials of a tenant. +func GoLooseCredentialEnv(slug string) (idEnv, secretEnv string) { + prefix := "GO_LOOSE_" + strings.ToUpper(slug) + return prefix + "_CLIENT_ID", prefix + "_CLIENT_SECRET" +} + func Load() (Config, error) { ttl, err := time.ParseDuration(env("TOKEN_TTL", "8h")) if err != nil { @@ -62,15 +70,16 @@ func loadGoLoose() GoLooseConfig { CAFile: os.Getenv("GO_LOOSE_CA_FILE"), Tenants: map[string]GoLooseTenant{}, } - add := func(slug, idEnv, secretEnv string) { + add := func(slug string) { + idEnv, secretEnv := GoLooseCredentialEnv(slug) id, secret := os.Getenv(idEnv), os.Getenv(secretEnv) if id == "" || secret == "" { return } cfg.Tenants[slug] = GoLooseTenant{ClientID: id, ClientSecret: secret} } - add("nmbs", "GO_LOOSE_NMBS_CLIENT_ID", "GO_LOOSE_NMBS_CLIENT_SECRET") - add("ypto", "GO_LOOSE_YPTO_CLIENT_ID", "GO_LOOSE_YPTO_CLIENT_SECRET") + add("nmbs") + add("ypto") return cfg } diff --git a/backend/internal/web/handler/sso.go b/backend/internal/web/handler/sso.go index 5d799e0..d4e333e 100644 --- a/backend/internal/web/handler/sso.go +++ b/backend/internal/web/handler/sso.go @@ -2,10 +2,12 @@ package handler import ( "errors" + "fmt" "net/http" goloose "github.com/danyel/go-loose/client" + "github.com/danyel/go-guess/backend/internal/config" "github.com/danyel/go-guess/backend/internal/golooseauth" webmapper "github.com/danyel/go-guess/backend/internal/web/mapper" webmodel "github.com/danyel/go-guess/backend/internal/web/model" @@ -14,6 +16,8 @@ import ( var ( errMissingSession = errors.New("missing Go Loose session") errTenantMismatch = errors.New("Go Loose session does not match this tenant") + errLoginDisabled = errors.New("Go Loose browser login is not enabled") + errNoTenantHost = errors.New("this host does not identify a configured tenant") ) func (h *Handler) SetBrowserAuth(auth *golooseauth.Registry) { @@ -28,27 +32,27 @@ func (h *Handler) AppDomain() string { } func (h *Handler) BeginExternalLogin(w http.ResponseWriter, r *http.Request) { - auth, ok := h.externalAuth(r) + auth, slug, ok := h.externalAuthWithSlug(r) if !ok { - http.NotFound(w, r) + writeExternalAuthError(w, h, slug) return } auth.LoginHandler(w, r) } func (h *Handler) CompleteExternalLogin(w http.ResponseWriter, r *http.Request) { - auth, ok := h.externalAuth(r) + auth, slug, ok := h.externalAuthWithSlug(r) if !ok { - http.NotFound(w, r) + writeExternalAuthError(w, h, slug) return } auth.CallbackHandler(w, r) } func (h *Handler) ExternalLogout(w http.ResponseWriter, r *http.Request) { - auth, ok := h.externalAuth(r) + auth, slug, ok := h.externalAuthWithSlug(r) if !ok { - http.NotFound(w, r) + writeExternalAuthError(w, h, slug) return } auth.LogoutHandler(w, r) @@ -57,7 +61,7 @@ func (h *Handler) ExternalLogout(w http.ResponseWriter, r *http.Request) { func (h *Handler) ExternalSession(w http.ResponseWriter, r *http.Request) { auth, slug, ok := h.externalAuthWithSlug(r) if !ok { - http.NotFound(w, r) + writeExternalAuthError(w, h, slug) return } if _, err := r.Cookie(golooseauth.SessionCookie); err != nil { @@ -90,3 +94,17 @@ func (h *Handler) externalAuthWithSlug(r *http.Request) (*goloose.BrowserAuth, s } return h.browser.ForHost(r.Host) } + +func writeExternalAuthError(w http.ResponseWriter, h *Handler, slug string) { + if h.browser == nil { + writeError(w, http.StatusNotFound, errLoginDisabled) + return + } + if slug == "" { + writeError(w, http.StatusNotFound, errNoTenantHost) + return + } + idEnv, secretEnv := config.GoLooseCredentialEnv(slug) + writeError(w, http.StatusServiceUnavailable, + fmt.Errorf("Go Loose login is not configured for tenant %q: set %s and %s, then restart the API", slug, idEnv, secretEnv)) +} diff --git a/backend/internal/web/router/router_test.go b/backend/internal/web/router/router_test.go index c4011d7..2e815cb 100644 --- a/backend/internal/web/router/router_test.go +++ b/backend/internal/web/router/router_test.go @@ -341,6 +341,52 @@ func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) { } } +func TestTenantHostLoginExplainsUnconfiguredGoLooseTenant(t *testing.T) { + h := newTestHandler(t, nil) + browser, err := golooseauth.New(config.GoLooseConfig{ + AuthDomain: "auth.dev", + AppDomain: "guess.dev", + Tenants: map[string]config.GoLooseTenant{"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}}, + }) + if err != nil { + t.Fatal(err) + } + h.SetBrowserAuth(browser) + api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173") + + request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil) + request.Host = "ypto.guess.dev" + response := httptest.NewRecorder() + api.ServeHTTP(response, request) + if response.Code != http.StatusServiceUnavailable || + !strings.Contains(response.Body.String(), "GO_LOOSE_YPTO_CLIENT_ID") { + t.Fatalf("ypto login = %d %s", response.Code, response.Body.String()) + } + + request = httptest.NewRequest(http.MethodGet, "/api/auth/login", nil) + request.Host = "guess.dev" + response = httptest.NewRecorder() + api.ServeHTTP(response, request) + if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "tenant") { + t.Fatalf("apex login = %d %s", response.Code, response.Body.String()) + } +} + +func TestLoginWithoutGoLooseRegistryIsNotFound(t *testing.T) { + api := New( + newTestHandler(t, nil), + security.NewTokenManager("01234567890123456789012345678901", time.Hour), + "http://localhost:5173", + ) + request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil) + request.Host = "nmbs.guess.dev" + response := httptest.NewRecorder() + api.ServeHTTP(response, request) + if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "not enabled") { + t.Fatalf("login without registry = %d %s", response.Code, response.Body.String()) + } +} + type stubDriver struct{} func (stubDriver) Open(string) (driver.Conn, error) { return stubConn{}, nil } diff --git a/deploy/helm/go-guess/Chart.yaml b/deploy/helm/go-guess/Chart.yaml index 7d5d697..d95a024 100644 --- a/deploy/helm/go-guess/Chart.yaml +++ b/deploy/helm/go-guess/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: go-guess -description: Go Guess recruitment workspace +description: Go Guess recruitment workspace with per-tenant web hosts type: application -version: 0.2.0 -appVersion: "0.2.0" +version: 0.3.0 +appVersion: "0.3.0" \ No newline at end of file diff --git a/deploy/helm/go-guess/templates/NOTES.txt b/deploy/helm/go-guess/templates/NOTES.txt index 5b2ffba..0de8882 100644 --- a/deploy/helm/go-guess/templates/NOTES.txt +++ b/deploy/helm/go-guess/templates/NOTES.txt @@ -1,17 +1,48 @@ -Go Guess has been installed in namespace {{ .Release.Namespace }}. -{{- if .Values.goLoose.enabled }} -Browser sign-in uses Go Loose at https://.{{ .Values.goLoose.authDomain }}. -{{- else }} -Browser sign-in uses the application password login. Enable goLoose to redirect tenant hosts. +Go Guess is installed in namespace {{ .Release.Namespace }}. + +Components: + API service/{{ include "go-guess.apiServiceName" . }}:{{ .Values.api.service.port }} (cluster-internal) + Web service/{{ include "go-guess.webDeploymentName" . }}:{{ .Values.web.service.port }} + Ingress {{ if .Values.ingress.enabled }}class {{ if .Values.ingress.className }}{{ .Values.ingress.className }}{{ else }}default{{ end }}{{ else }}disabled{{ end }} +{{ if .Values.ingress.enabled }} +Tenant URLs: +{{- range .Values.tenants }} + {{ if $.Values.ingress.tls.enabled }}https{{ else }}http{{ end }}://{{ printf "%s.%s" . $.Values.domain }} {{- end }} -{{- if .Values.ingress.enabled }} -Open {{ .Values.app.frontendURL }} -{{- else if eq .Values.service.type "NodePort" }} -The application is exposed on cluster node port {{ .Values.service.nodePort }}. -Open {{ .Values.app.frontendURL }} through the external reverse proxy. -{{- else }} -Run: - kubectl -n {{ .Release.Namespace }} port-forward service/{{ include "go-guess.fullname" . }} 8080:{{ .Values.service.port }} -Then open http://127.0.0.1:8080. +Point every tenant host at the ingress controller address, for example in +/etc/hosts: +{{- range .Values.tenants }} + {{ printf "%s.%s" . $.Values.domain }} {{- end }} +{{- if and .Values.ingress.tls.enabled .Values.certManager.enabled (not .Values.ingress.tls.existingSecret) }} + +The certificate is self-signed, so trust the chart CA in every browser or client +that opens these hosts: + kubectl -n {{ .Release.Namespace }} get secret {{ include "go-guess.certManagerCASecretName" . }} \ + -o jsonpath='{.data.ca\.crt}' | base64 -d > go-guess-ca.crt +{{- end }} +{{- if .Values.api.goLoose.enabled }} + +Browser sign-in redirects to https://.{{ .Values.api.goLoose.authDomain }}: +{{- range $tenant, $credentials := .Values.api.goLoose.tenants }} +{{- if and $credentials.clientID $credentials.clientSecret }} + {{ $tenant }}: Go Loose browser sign-in +{{- else }} + {{ $tenant }}: no client credentials configured, password login only +{{- end }} +{{- end }} +{{- else }} + +Browser sign-in uses the application password login. +{{- end }} +{{- else if and (eq .Values.web.service.type "NodePort") .Values.web.service.nodePort }} + +The web Service is exposed on cluster node port {{ .Values.web.service.nodePort }}. +{{- else }} + +Run: + kubectl -n {{ .Release.Namespace }} port-forward service/{{ include "go-guess.webDeploymentName" . }} 8080:{{ .Values.web.service.port }} + +Then open http://127.0.0.1:8080. +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-guess/templates/_helpers.tpl b/deploy/helm/go-guess/templates/_helpers.tpl index 50fe08c..f977336 100644 --- a/deploy/helm/go-guess/templates/_helpers.tpl +++ b/deploy/helm/go-guess/templates/_helpers.tpl @@ -39,3 +39,72 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{- define "go-guess.rabbitmqName" -}} {{- printf "%s-rabbitmq" (include "go-guess.fullname" .) }} {{- end }} + +{{- define "go-guess.apiDeploymentName" -}} +{{- printf "%s-api" (include "go-guess.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "go-guess.webDeploymentName" -}} +{{- printf "%s-web" (include "go-guess.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* DNS name the API is reachable under. The web image hardcodes "api:8080". */}} +{{- define "go-guess.apiServiceName" -}} +{{- default "api" .Values.api.service.name }} +{{- end }} + +{{/* Host serving one tenant, for example nmbs.guess.local. */}} +{{- define "go-guess.tenantHost" -}} +{{- printf "%s.%s" (index . 0) (index . 1) }} +{{- end }} + +{{/* Fallback public URL for invitation and meeting links. */}} +{{- define "go-guess.frontendURL" -}} +{{- if .Values.api.frontendURL }} +{{- .Values.api.frontendURL }} +{{- else if .Values.tenants }} +{{- printf "https://%s.%s" (first .Values.tenants) .Values.domain }} +{{- end }} +{{- end }} + +{{/* Secret holding the wildcard-per-tenant web certificate. */}} +{{- define "go-guess.tlsSecretName" -}} +{{- if .Values.ingress.tls.existingSecret }} +{{- .Values.ingress.tls.existingSecret }} +{{- else if .Values.ingress.tls.secretName }} +{{- .Values.ingress.tls.secretName }} +{{- else }} +{{- printf "%s-web-tls" (include "go-guess.fullname" .) }} +{{- end }} +{{- end }} + +{{/* Cluster-scoped by default so one issuer signs every tenant release. */}} +{{- define "go-guess.certManagerIssuerRef" -}} +{{- if .Values.certManager.issuerRef }} +toYaml .Values.certManager.issuerRef +{{- else }} +name: selfsigned +kind: Issuer +{{- end }} +{{- end }} + +{{- define "go-guess.validate" -}} +{{- if not .Values.tenants }} +{{- fail "tenants must list at least one tenant slug" }} +{{- end }} +{{- if not .Values.domain }} +{{- fail "domain is required" }} +{{- end }} +{{- if and .Values.ingress.enabled .Values.ingress.tls.enabled (not .Values.certManager.enabled) (not .Values.ingress.tls.existingSecret) }} +{{- fail "ingress.tls.enabled requires certManager.enabled or ingress.tls.existingSecret" }} +{{- end }} +{{- end }} + +{{/* Secret holding the CA that signs the leaf web certificate. */}} +{{- define "go-guess.certManagerCASecretName" -}} +{{- if .Values.certManager.caSecretName }} +{{- .Values.certManager.caSecretName }} +{{- else }} +{{- printf "%s-web-ca" (include "go-guess.fullname" .) }} +{{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-guess/templates/app-deployment.yaml b/deploy/helm/go-guess/templates/app-deployment.yaml deleted file mode 100644 index d271706..0000000 --- a/deploy/helm/go-guess/templates/app-deployment.yaml +++ /dev/null @@ -1,105 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "go-guess.fullname" . }} - labels: - {{- include "go-guess.labels" . | nindent 4 }} -spec: - replicas: {{ .Values.replicaCount }} - strategy: - type: {{ .Values.strategy.type }} - selector: - matchLabels: - {{- include "go-guess.selectorLabels" . | nindent 6 }} - app.kubernetes.io/component: application - template: - metadata: - annotations: - checksum/secrets: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} - {{- with .Values.podAnnotations }} - {{- toYaml . | nindent 8 }} - {{- end }} - labels: - {{- include "go-guess.selectorLabels" . | nindent 8 }} - app.kubernetes.io/component: application - {{- with .Values.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} - spec: - automountServiceAccountToken: false - {{- with .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - securityContext: - {{- toYaml .Values.podSecurityContext | nindent 8 }} - containers: - - name: go-guess - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - securityContext: - {{- toYaml .Values.securityContext | nindent 12 }} - env: - - name: APP_ENV - value: {{ .Values.app.environment | quote }} - - name: FRONTEND_URL - value: {{ .Values.app.frontendURL | quote }} - - name: TOKEN_TTL - value: {{ .Values.app.tokenTTL | quote }} - - name: MAX_UPLOAD_MB - value: {{ .Values.app.maxUploadMB | quote }} - - name: DATABASE_URL - valueFrom: - secretKeyRef: - name: {{ include "go-guess.secretName" . }} - key: database-url - - name: RABBITMQ_URL - valueFrom: - secretKeyRef: - name: {{ include "go-guess.secretName" . }} - key: rabbitmq-url - - name: JWT_SECRET - valueFrom: - secretKeyRef: - name: {{ include "go-guess.secretName" . }} - key: jwt-secret - ports: - - name: http - containerPort: 8080 - protocol: TCP - readinessProbe: - httpGet: - path: /api/health - port: http - initialDelaySeconds: 5 - periodSeconds: 5 - timeoutSeconds: 3 - failureThreshold: 12 - livenessProbe: - httpGet: - path: /api/health - port: http - initialDelaySeconds: 20 - periodSeconds: 10 - timeoutSeconds: 3 - failureThreshold: 3 - resources: - {{- toYaml .Values.resources | nindent 12 }} - volumeMounts: - - name: temporary-files - mountPath: /tmp - volumes: - - name: temporary-files - emptyDir: {} - {{- with .Values.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} diff --git a/deploy/helm/go-guess/templates/app-service.yaml b/deploy/helm/go-guess/templates/app-service.yaml deleted file mode 100644 index 1d5e71a..0000000 --- a/deploy/helm/go-guess/templates/app-service.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ include "go-guess.fullname" . }} - labels: - {{- include "go-guess.labels" . | nindent 4 }} -spec: - type: {{ .Values.service.type }} - selector: - {{- include "go-guess.selectorLabels" . | nindent 4 }} - app.kubernetes.io/component: application - ports: - - name: http - port: {{ .Values.service.port }} - targetPort: http - protocol: TCP - {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }} - nodePort: {{ .Values.service.nodePort }} - {{- end }} diff --git a/deploy/helm/go-guess/templates/ingress.yaml b/deploy/helm/go-guess/templates/ingress.yaml index 9b7db99..4c85e37 100644 --- a/deploy/helm/go-guess/templates/ingress.yaml +++ b/deploy/helm/go-guess/templates/ingress.yaml @@ -1,4 +1,5 @@ {{- if .Values.ingress.enabled }} +{{- $webService := include "go-guess.webDeploymentName" . }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: @@ -13,23 +14,25 @@ spec: {{- with .Values.ingress.className }} ingressClassName: {{ . }} {{- end }} - {{- with .Values.ingress.tls }} + {{- if .Values.ingress.tls.enabled }} tls: - {{- toYaml . | nindent 4 }} + - hosts: + {{- range .Values.tenants }} + - {{ printf "%s.%s" . $.Values.domain | quote }} + {{- end }} + secretName: {{ include "go-guess.tlsSecretName" . }} {{- end }} rules: - {{- range .Values.ingress.hosts }} - - host: {{ .host | quote }} + {{- range .Values.tenants }} + - host: {{ printf "%s.%s" . $.Values.domain | quote }} http: paths: - {{- range .paths }} - - path: {{ .path }} - pathType: {{ .pathType }} + - path: {{ $.Values.ingress.path }} + pathType: {{ $.Values.ingress.pathType }} backend: service: - name: {{ include "go-guess.fullname" $ }} + name: {{ $webService }} port: - number: {{ $.Values.service.port }} - {{- end }} + number: {{ $.Values.web.service.port }} {{- end }} -{{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-guess/templates/secret.yaml b/deploy/helm/go-guess/templates/secret.yaml index f6489ec..ccf3033 100644 --- a/deploy/helm/go-guess/templates/secret.yaml +++ b/deploy/helm/go-guess/templates/secret.yaml @@ -32,3 +32,14 @@ data: rabbitmq-url: {{ $rabbitmqURL | b64enc | quote }} postgresql-password: {{ $postgresPassword | b64enc | quote }} rabbitmq-password: {{ $rabbitmqPassword | b64enc | quote }} + {{- if .Values.api.goLoose.enabled }} + {{- range $tenant, $credentials := .Values.api.goLoose.tenants }} + {{- $prefix := printf "go-loose-%s" (lower $tenant) }} + {{- if $credentials.clientID }} + {{ printf "%s-client-id" $prefix }}: {{ $credentials.clientID | b64enc | quote }} + {{- end }} + {{- if $credentials.clientSecret }} + {{ printf "%s-client-secret" $prefix }}: {{ $credentials.clientSecret | b64enc | quote }} + {{- end }} + {{- end }} + {{- end }} \ No newline at end of file diff --git a/deploy/helm/go-guess/templates/tests/test-connection.yaml b/deploy/helm/go-guess/templates/tests/test-connection.yaml index ea6d661..8b6b4de 100644 --- a/deploy/helm/go-guess/templates/tests/test-connection.yaml +++ b/deploy/helm/go-guess/templates/tests/test-connection.yaml @@ -17,4 +17,11 @@ spec: - wget args: - -qO- - - "http://{{ include "go-guess.fullname" . }}:{{ .Values.service.port }}/api/health" + - "http://{{ include "go-guess.apiServiceName" . }}:{{ .Values.api.service.port }}/api/health" + - name: wget-web + image: busybox:1.37 + command: + - wget + args: + - -qO- + - "http://{{ include "go-guess.webDeploymentName" . }}:{{ .Values.web.service.port }}/" \ No newline at end of file diff --git a/deploy/helm/go-guess/values-development.yaml b/deploy/helm/go-guess/values-development.yaml index ff0d4f4..0edd694 100644 --- a/deploy/helm/go-guess/values-development.yaml +++ b/deploy/helm/go-guess/values-development.yaml @@ -1,25 +1,43 @@ -image: - tag: latest - pullPolicy: Always +# Development profile: demo fixtures, ephemeral database and broker storage, and +# `latest` image tags. TLS still uses the chart's self-signed certificate so +# https://.guess.local behaves like the production profile. +domain: guess.local -app: +tenants: + - nmbs + - ypto + +api: environment: development - frontendURL: http://localhost:30080 + image: + tag: latest + pullPolicy: Always + goLoose: + enabled: true + authDomain: auth.dev + resources: + requests: + cpu: 50m + memory: 96Mi + limits: + cpu: 300m + memory: 384Mi -service: - type: NodePort - nodePort: 30080 +web: + replicaCount: 1 + image: + tag: latest + pullPolicy: Always + resources: + requests: + cpu: 25m + memory: 32Mi + limits: + cpu: 200m + memory: 96Mi ingress: - enabled: false - -resources: - requests: - cpu: 50m - memory: 96Mi - limits: - cpu: 300m - memory: 384Mi + enabled: true postgresql: persistence: @@ -41,4 +59,4 @@ rabbitmq: memory: 96Mi limits: cpu: 300m - memory: 384Mi + memory: 384Mi \ No newline at end of file diff --git a/deploy/helm/go-guess/values-production.yaml b/deploy/helm/go-guess/values-production.yaml index bf19e1a..6d7cee5 100644 --- a/deploy/helm/go-guess/values-production.yaml +++ b/deploy/helm/go-guess/values-production.yaml @@ -1,25 +1,45 @@ -image: - tag: latest - pullPolicy: IfNotPresent +# Production profile: production fixtures, persistent volumes, released image +# tags, and self-signed certificates served for https://.guess.local. +domain: guess.local -app: +tenants: + - nmbs + - ypto + +api: environment: production - frontendURL: https://goguess.urpi.be + image: + tag: "0.0.1" + pullPolicy: IfNotPresent + goLoose: + enabled: true + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: "1" + memory: 768Mi -service: - type: NodePort - nodePort: 31374 +web: + replicaCount: 2 + image: + tag: "0.0.1" + pullPolicy: IfNotPresent + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 300m + memory: 128Mi ingress: - enabled: false + enabled: true + className: nginx -resources: - requests: - cpu: 200m - memory: 256Mi - limits: - cpu: "1" - memory: 768Mi +certManager: + enabled: true postgresql: persistence: @@ -29,4 +49,4 @@ postgresql: rabbitmq: persistence: enabled: true - size: 8Gi + size: 8Gi \ No newline at end of file diff --git a/deploy/helm/go-guess/values.yaml b/deploy/helm/go-guess/values.yaml index cc268de..da1bdda 100644 --- a/deploy/helm/go-guess/values.yaml +++ b/deploy/helm/go-guess/values.yaml @@ -1,30 +1,142 @@ nameOverride: "" fullnameOverride: "" -replicaCount: 1 +# Public application domain. Every tenant is served from https://.. +domain: guess.local -image: - repository: batty1039.startdedicated.net:5000/go-guess - tag: latest - pullPolicy: IfNotPresent +# Tenant slugs. Each one gets its own ingress host and TLS SAN. +tenants: + - nmbs + - ypto imagePullSecrets: [] -app: - environment: production - frontendURL: http://localhost:8080 +api: + replicaCount: 1 + image: + repository: batty1039.startdedicated.net:5000/go-guess-api + tag: "0.0.1" + pullPolicy: IfNotPresent + environment: development + # Public base URL used when building invitation and meeting links. Defaults to + # https://. when empty. + frontendURL: "" tokenTTL: 8h maxUploadMB: 10 + service: + # The web image proxies /api/ to the literal upstream name "api:8080", so this + # Service name has to stay "api" unless the web image is rebuilt. + name: api + type: ClusterIP + port: 8080 + goLoose: + enabled: false + authDomain: auth.dev + ca: + # Mount an internal CA bundle and point GO_LOOSE_CA_FILE at it. + existingSecret: "" + key: ca.crt + mountPath: /certs/local-ca.crt + # Per-tenant browser authentication credentials, written to the release secret. + tenants: + nmbs: + clientID: "" + clientSecret: "" + ypto: + clientID: "" + clientSecret: "" + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + podAnnotations: {} + podLabels: {} + # The image runs as its own "app" user (uid 100), so no uid is forced here: + # runAsNonRoot then validates whatever the image declares. + podSecurityContext: {} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + # The API container applies migrations and seeds before serving, so old pods must + # be gone before the new one starts. + strategy: + type: Recreate -goLoose: - enabled: false - authDomain: auth.dev - appDomain: guess.dev - ca: +web: + replicaCount: 2 + image: + repository: batty1039.startdedicated.net:5000/go-guess-web + tag: "0.0.1" + pullPolicy: IfNotPresent + service: + type: ClusterIP + port: 80 + nodePort: null + resources: + requests: + cpu: 25m + memory: 32Mi + limits: + cpu: 300m + memory: 128Mi + podAnnotations: {} + podLabels: {} + # nginx workers run as uid 101, which the image already declares. + podSecurityContext: {} + # nginx binds the privileged port 80 and forks workers as the nginx user, so it + # needs the root user plus a small capability set and writable scratch paths. + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + add: + - CHOWN + - SETGID + - SETUID + - NET_BIND_SERVICE + readOnlyRootFilesystem: false + runAsNonRoot: false + runAsUser: 0 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + +ingress: + enabled: true + className: nginx + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: 10m + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-buffering: "off" + nginx.ingress.kubernetes.io/ssl-redirect: "true" + path: / + pathType: Prefix + tls: + enabled: true + # Use a certificate you already manage instead of the chart-issued one. existingSecret: "" - key: ca.crt - mountPath: /certs/local-ca.crt - tenants: [] + # Defaults to -web-tls when empty and certManager.enabled is true. + secretName: "" + +certManager: + # Requires cert-manager in the cluster: make cert-manager-install. + enabled: true + # Reuse an existing cert-manager issuer instead of creating a self-signed one. + issuerRef: {} + duration: 8760h + renewBefore: 720h + caSecretName: "" secrets: jwtSecret: "" @@ -33,49 +145,6 @@ secrets: postgresqlPassword: "" rabbitmqPassword: "" -service: - type: ClusterIP - port: 80 - nodePort: null - -ingress: - enabled: false - className: nginx - annotations: {} - hosts: - - host: go-guess.local - paths: - - path: / - pathType: Prefix - tls: [] - -resources: - requests: - cpu: 100m - memory: 128Mi - limits: - cpu: 500m - memory: 512Mi - -podAnnotations: {} -podLabels: {} - -podSecurityContext: - fsGroup: 10001 - fsGroupChangePolicy: OnRootMismatch - -securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsUser: 10001 - -strategy: - type: Recreate - postgresql: enabled: true image: @@ -119,4 +188,4 @@ rabbitmq: nodeSelector: {} tolerations: [] -affinity: {} +affinity: {} \ No newline at end of file