From 2967f1a5af7d0045269fd99099b7e1a6e02e9a31 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sat, 10 Oct 2026 02:30:01 +0200 Subject: [PATCH] refactor --- .env.example | 4 +- Makefile | 66 ++++++++++- README.md | 5 +- compose.yaml | 2 +- deploy/dev/values-dev-cluster.yaml | 20 ++-- deploy/helm/go-bananas/templates/NOTES.txt | 2 +- deploy/helm/go-bananas/templates/_helpers.tpl | 4 +- .../helm/go-bananas/templates/deployment.yaml | 22 ++-- deploy/helm/go-bananas/values-dev.yaml | 23 ++-- deploy/helm/go-bananas/values-lcl.yaml | 10 +- deploy/helm/go-bananas/values-tst.yaml | 24 ++-- deploy/helm/go-bananas/values.yaml | 4 +- docs/deployment.md | 76 +++++++++++- docs/development.md | 13 +- internal/auth/oidc.go | 2 +- internal/config/config.go | 2 +- internal/httpapi/router_test.go | 8 +- internal/store/repositories.go | 4 +- sdk/tsconfig.json | 1 + web/eslint.config.js | 6 +- web/package.json | 6 +- web/src/components/admin-theme-context.ts | 5 + web/src/components/admin-theme.test.tsx | 74 ++++++++++-- web/src/components/admin-theme.tsx | 112 +++++++++++++----- web/src/components/shell.tsx | 94 +++++++++------ web/src/global.d.ts | 12 +- web/src/routes/dashboard.tsx | 2 +- web/src/vite-env.d.ts | 2 + web/tsconfig.app.json | 8 +- web/vite.config.ts | 10 +- web/vitest.config.ts | 11 ++ 31 files changed, 469 insertions(+), 165 deletions(-) diff --git a/.env.example b/.env.example index 855317e..24c4547 100644 --- a/.env.example +++ b/.env.example @@ -55,7 +55,7 @@ BANANAS_PUBLIC_BASE_URL=http://localhost:8080 # The development realm. A production deployment points this at its own provider; # only the issuer, client credentials and group mapping are provider-specific. -BANANAS_OIDC_ISSUER=https://auth-dev.urpi.be/realms/urpi +BANANAS_OIDC_ISSUER=https://auth.lcl-urpi.be/realms/urpi BANANAS_OIDC_CLIENT_ID=go-bananas # BANANAS_OIDC_CLIENT_SECRET=read-from-a-secret @@ -163,4 +163,4 @@ BANANAS_ENABLE_PROFILER=false # Passed by docker-bake and goreleaser rather than set by hand. # VERSION=dev # COMMIT=unknown -# BUILD_DATE=unknown \ No newline at end of file +# BUILD_DATE=unknown diff --git a/Makefile b/Makefile index 03ecdf1..6ffcca4 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) -REGISTRY ?= nexus.urpi.be +REGISTRY ?= nexus.urpi.be/docker-hosted IMAGE_NAME ?= $(REGISTRY)/$(BINARY) IMAGE_TAG ?= $(VERSION) PLATFORM ?= linux/amd64,linux/arm64 @@ -127,12 +127,13 @@ dev: ## Run the API with hot reload (Ctrl-C to stop) air -c .air.toml .PHONY: dev-api -dev-api: ## Run only the API server, no frontend +dev-api: ## Run the API (no SPA); Vite serves the UI, this serves the runtime SDK @BANANAS_ENVIRONMENT=development \ BANANAS_AUTH_MODE=dev \ BANANAS_HTTP_ADDR=:8080 \ BANANAS_DATABASE_PATH=./data/go-bananas.db \ - BANANAS_STATIC_DIR= BANANAS_SDK_DIR= \ + BANANAS_STATIC_DIR= \ + BANANAS_SDK_DIR=$(SDK_DIR)/dist \ $(GO) run -ldflags "$(LDFLAGS)" $(CMD) .PHONY: dev-web @@ -140,7 +141,7 @@ dev-web: ## Run the Vite dev server for the admin UI, proxying the API cd $(WEB_DIR) && $(NPM) run dev .PHONY: dev-all -dev-all: ## Run the API and the frontend dev server together +dev-all: build-sdk ## Run the API and the frontend dev server together @echo "API on http://localhost:8080, admin UI on http://localhost:5173" @trap 'kill 0' EXIT; \ $(MAKE) dev-api & \ @@ -375,7 +376,7 @@ helm-template: ## Render the chart with the default values helm-template-auth: ## Render the chart configured for OIDC $(HELM) template go-bananas deploy/helm/go-bananas \ --set identity.mode=oidc \ - --set identity.oidc.issuer=https://auth-dev.urpi.be/realms/urpi \ + --set identity.oidc.issuer=https://auth.lcl-urpi.be/realms/urpi \ --set identity.oidc.clientId=go-bananas \ --set identity.oidc.generateSecret=false \ --set identity.oidc.existingSecret=go-bananas-oidc \ @@ -392,6 +393,61 @@ helm-install: ## Install the chart into the current kube context ##@destructive helm-uninstall: ## Remove the release ##@destructive $(HELM) uninstall go-bananas --namespace go-bananas +##@ Local (lcl) + +# The lcl environment is the developer's local k3s cluster with a registry at +# localhost:5000 and a TLS edge serving *.intern-urpi.be. go-* apps deploy into the +# shared `go-insane-lcl` namespace. These targets use values-lcl.yaml. +# +# KUBECONFIG — the lcl kubeconfig. On this box the `local` kubecontext goes through a +# Rancher proxy that can 502; the direct k3s config is /etc/rancher/k3s/k3s.yaml. +# Set it to deploy without depending on the proxy: KUBECONFIG=/etc/rancher/k3s/k3s.yaml make deploy-lcl +# LCL_TAG — image tag. Defaults to a timestamp so a rebuild is always re-pulled; +# `IfNotPresent` would otherwise serve a stale `0.0.1` from the node cache. + +LCL_REGISTRY ?= localhost:5000 +LCL_REPO ?= go-bananas +LCL_TAG ?= $(shell date -u +%Y%m%d%H%M%S) +LCL_NAMESPACE ?= go-insane-lcl +LCL_VALUES = deploy/helm/go-bananas/values-lcl.yaml + +.PHONY: image-lcl +image-lcl: ## Build and push the image to the local registry ##@destructive + $(DOCKER) buildx build \ + --build-arg VERSION=$(VERSION) \ + --build-arg COMMIT=$(COMMIT) \ + --build-arg BUILD_DATE=$(BUILD_DATE) \ + --tag $(LCL_REGISTRY)/$(LCL_REPO):$(LCL_TAG) \ + --push \ + . + +.PHONY: helm-template-lcl +helm-template-lcl: ## Render the lcl Helm manifest + $(HELM) template go-bananas deploy/helm/go-bananas \ + --namespace $(LCL_NAMESPACE) \ + --values $(LCL_VALUES) \ + --set-string image.tag=$(LCL_TAG) + +.PHONY: helm-install-lcl +helm-install-lcl: ## Deploy go-bananas to the local (lcl) environment ##@destructive + $(HELM) upgrade --install go-bananas deploy/helm/go-bananas \ + --namespace $(LCL_NAMESPACE) --create-namespace \ + --values $(LCL_VALUES) \ + --set-string image.tag=$(LCL_TAG) \ + --wait --timeout 5m + +.PHONY: helm-uninstall-lcl +helm-uninstall-lcl: ## Remove the lcl release ##@destructive + $(HELM) uninstall go-bananas --namespace $(LCL_NAMESPACE) + +.PHONY: deploy-lcl +deploy-lcl: image-lcl helm-install-lcl ## Build, push, deploy to lcl, then verify ##@destructive + @echo "waiting for rollout ($(LCL_NAMESPACE)/go-bananas)..." + @kubectl -n $(LCL_NAMESPACE) rollout status deploy/go-bananas --timeout=120s + @echo "=== runtime manifest through the TLS edge ===" + @curl -sk https://bananas-lcl.intern-urpi.be/rt/v1/manifest.json -o /dev/null -w 'manifest.json -> %{http_code}\n' || true + @echo "deployed image: $(LCL_REGISTRY)/$(LCL_REPO):$(LCL_TAG)" + ##@ Docs .PHONY: docs-serve diff --git a/README.md b/README.md index 4a322d9..6a73923 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ helm upgrade --install go-bananas oci://ghcr.io/urpi/charts/go-bananas \ --set identity.oidc.existingSecret=go-bananas-auth ``` -The chart defaults to the shared development realm at `auth-dev.urpi.be`. See +The chart defaults to the shared development realm at `auth.lcl-urpi.be`. See [docs/deployment.md](docs/deployment.md) for the full production checklist. --- @@ -462,8 +462,9 @@ Requires Go 1.27 and Node 22 or later. `make tools` installs `air`, | [docs/operations.md](docs/operations.md) | Monitoring, backup, runbook | | [docs/security.md](docs/security.md) | Threat model and hardening | | [docs/styling-contract.md](docs/styling-contract.md) | The shared styling contract, and how an application adopts it | +| [docs/dev-infrastructure.md](docs/dev-infrastructure.md) | **This machine's dev infrastructure: audit, port map, fixes, and the issue log to append to** | | [docs/adr/](docs/adr/) | Architecture decision records | ## Licence -MIT. \ No newline at end of file +MIT. diff --git a/compose.yaml b/compose.yaml index c051152..e2300a3 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,7 +6,7 @@ # # The `oidc` profile adds a mock OpenID Connect provider, so the authentication # flow can be exercised end to end without pointing at a real realm. The `oidc` -# profile uses the mock because auth-dev.urpi.be is a shared development realm and a +# profile uses the mock because auth.lcl-urpi.be is a shared development realm and a # local stack should not depend on it being reachable. # # Usage: diff --git a/deploy/dev/values-dev-cluster.yaml b/deploy/dev/values-dev-cluster.yaml index cc6c09c..f91cffb 100644 --- a/deploy/dev/values-dev-cluster.yaml +++ b/deploy/dev/values-dev-cluster.yaml @@ -21,12 +21,8 @@ # reproducible rather than "whatever worked when it was deployed". image: - # The development cluster pulls from a private registry whose certificate is valid - # for a different name, so the host and the port have to stay in one piece: putting - # the port in `repository` renders an image reference without it, and the kubelet - # then resolves the registry over https on port 443. - registry: batty1039.startdedicated.net:5000 - repository: go-bananas + registry: nexus.urpi.be + repository: docker-hosted/go-bananas # The development registry is tagged `latest` rather than pinned to a version, to # match every other release in this namespace. Consequently the pull policy must be # Always: with IfNotPresent a node keeps serving the image it already has, and a @@ -39,7 +35,7 @@ config: # refuses both of these outside a development environment, so this pair cannot be # promoted to production by accident. environment: development - publicBaseUrl: https://bananas-dev.urpi.be + publicBaseUrl: https://bananas.dev-urpi.be identity: # dev, not oidc. @@ -59,11 +55,11 @@ identity: # helm upgrade ... --set identity.mode=oidc \ # --set identity.oidc.generateSecret=false \ # --set identity.oidc.existingSecret=go-bananas-auth \ - # --set identity.oidc.issuer=https://auth-dev.urpi.be \ + # --set identity.oidc.issuer=https://auth.dev-urpi.be \ # --set identity.oidc.clientId=go-bananas mode: dev oidc: - issuer: https://auth-dev.urpi.be + issuer: https://auth.dev-urpi.be clientId: go-bananas existingSecret: go-bananas-auth generateSecret: false @@ -74,17 +70,17 @@ ingress: cert-manager.io/cluster-issuer: letsencrypt-staging nginx.ingress.kubernetes.io/ssl-redirect: "true" hosts: - - host: bananas-dev.urpi.be + - host: bananas.dev-urpi.be paths: - path: / pathType: Prefix tls: - secretName: go-bananas-tls hosts: - - bananas-dev.urpi.be + - bananas.dev-urpi.be deployment: # One replica. The volume is ReadWriteOnce, so a second replica cannot mount it and # would sit in ImagePullBackOff-equivalent limbo forever. Scaling needs either this # changed to 1 or a ReadWriteMany filesystem. - replicaCount: 1 \ No newline at end of file + replicaCount: 1 diff --git a/deploy/helm/go-bananas/templates/NOTES.txt b/deploy/helm/go-bananas/templates/NOTES.txt index b4ef892..5e26ff4 100644 --- a/deploy/helm/go-bananas/templates/NOTES.txt +++ b/deploy/helm/go-bananas/templates/NOTES.txt @@ -60,7 +60,7 @@ Authentication is set to "{{ .Values.identity.mode }}". Every request is current treated as an administrator. Before exposing this to a network, set: identity.mode=oidc - identity.oidc.issuer=https://auth-dev.urpi.be/realms/ + identity.oidc.issuer=https://auth.dev-urpi.be/realms/ identity.oidc.clientId=go-bananas identity.oidc.existingSecret= {{- end }} diff --git a/deploy/helm/go-bananas/templates/_helpers.tpl b/deploy/helm/go-bananas/templates/_helpers.tpl index d94981e..0798d80 100644 --- a/deploy/helm/go-bananas/templates/_helpers.tpl +++ b/deploy/helm/go-bananas/templates/_helpers.tpl @@ -167,7 +167,7 @@ CrashLoopBackOff to diagnose. Each check names the value to change. {{- if eq $mode "oidc" -}} {{- if not .Values.identity.oidc.issuer -}} - {{- fail "identity.oidc.issuer is required when identity.mode is oidc. For the development realm this is https://auth-dev.urpi.be/realms/." -}} + {{- fail "identity.oidc.issuer is required when identity.mode is oidc. For the development realm this is https://auth.dev-urpi.be/realms/." -}} {{- end -}} {{- if not (regexMatch "^https?://" .Values.identity.oidc.issuer) -}} {{- fail (printf "identity.oidc.issuer %q is not a URL. It must include the scheme." .Values.identity.oidc.issuer) -}} @@ -253,4 +253,4 @@ The data volume name, whether it is a PVC or an emptyDir. {{- else -}} {{- printf "%s-data" (include "go-bananas.fullname" .) -}} {{- end -}} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/deploy/helm/go-bananas/templates/deployment.yaml b/deploy/helm/go-bananas/templates/deployment.yaml index f42cdb3..15df9ff 100644 --- a/deploy/helm/go-bananas/templates/deployment.yaml +++ b/deploy/helm/go-bananas/templates/deployment.yaml @@ -52,9 +52,6 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} spec: - {{- with .Values.image.pullPolicy }} - imagePullPolicy: {{ . }} - {{- end }} serviceAccountName: {{ include "go-bananas.serviceAccountName" . }} automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} {{- with .Values.serviceAccount.annotations }} @@ -75,19 +72,20 @@ spec: securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }} - {{- if .Values.deployment.lifecycle.preStop.enabled }} - # A preStop sleep gives the Service endpoints controller time to remove this - # pod from the endpoint list before the process starts refusing connections. - # Without it, a rolling update can drop requests that were already routed. - lifecycle: - preStop: - exec: - command: ["/bin/sh", "-c", "sleep {{ .Values.deployment.lifecycle.preStop.sleepSeconds }}"] - {{- end }} containers: - name: go-bananas image: {{ include "go-bananas.image" . }} imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- if .Values.deployment.lifecycle.preStop.enabled }} + # A preStop sleep gives the Service endpoints controller time to remove this + # pod from the endpoint list before the process starts refusing connections. + # Without it, a rolling update can drop requests that were already routed. + # lifecycle is a container field, not a podSpec field. + lifecycle: + preStop: + exec: + command: ["/bin/sh", "-c", "sleep {{ .Values.deployment.lifecycle.preStop.sleepSeconds }}"] + {{- end }} {{- with .Values.deployment.extraArgs }} args: {{- toYaml . | nindent 12 }} diff --git a/deploy/helm/go-bananas/values-dev.yaml b/deploy/helm/go-bananas/values-dev.yaml index 17c354f..9af84c8 100644 --- a/deploy/helm/go-bananas/values-dev.yaml +++ b/deploy/helm/go-bananas/values-dev.yaml @@ -2,7 +2,8 @@ nameOverride: "" fullnameOverride: "" -replicaCount: 1 +deployment: + replicaCount: 1 image: repository: nexus.urpi.be/docker-hosted/go-bananas @@ -13,6 +14,16 @@ service: type: ClusterIP port: 80 +# Spelled out here rather than inherited from values.yaml so the development +# issuer is stated where it is used. +identity: + mode: oidc + oidc: + issuer: https://auth.dev-urpi.be/realms/urpi + clientId: go-bananas + existingSecret: go-bananas-auth + generateSecret: false + ingress: enabled: true className: nginx @@ -20,20 +31,14 @@ ingress: cert-manager.io/cluster-issuer: letsencrypt-staging nginx.ingress.kubernetes.io/ssl-redirect: "true" hosts: - - host: bananas-dev.intern-urpi.be + - host: bananas.dev-urpi.be paths: - path: / pathType: Prefix tls: - secretName: go-bananas-tls hosts: - - bananas-dev.intern-urpi.be - -certManager: - enabled: true - issuerRef: - name: letsencrypt-staging - kind: ClusterIssuer + - bananas.dev-urpi.be resources: limits: diff --git a/deploy/helm/go-bananas/values-lcl.yaml b/deploy/helm/go-bananas/values-lcl.yaml index 366b2da..f64695b 100644 --- a/deploy/helm/go-bananas/values-lcl.yaml +++ b/deploy/helm/go-bananas/values-lcl.yaml @@ -1,5 +1,5 @@ # Local (LCL) values for go-bananas. -# Served at https://bananas-lcl.intern-urpi.be by the docker TLS edge. +# Served at https://bananas.lcl-urpi.be by the docker TLS edge. # # NOTE: this chart validates values against values.schema.json, which allows # only the keys defined there -- no certManager and no replicaCount here @@ -18,12 +18,12 @@ service: port: 80 # Local environment: development unlocks the local authentication bypass, so -# the deployment works without the shared Keycloak realm at auth-dev.urpi.be +# the deployment works without the shared Keycloak realm at auth.dev-urpi.be # (go-loose does not serve /realms/... endpoints, so the oidc default cannot # be pointed at it either). config: environment: development - publicBaseUrl: "https://bananas-lcl.intern-urpi.be" + publicBaseUrl: "https://bananas.lcl-urpi.be" identity: mode: dev @@ -33,14 +33,14 @@ ingress: className: traefik annotations: {} hosts: - - host: bananas-lcl.intern-urpi.be + - host: bananas.lcl-urpi.be paths: - path: / pathType: Prefix tls: - secretName: local-dev-cert hosts: - - bananas-lcl.intern-urpi.be + - bananas.lcl-urpi.be resources: limits: diff --git a/deploy/helm/go-bananas/values-tst.yaml b/deploy/helm/go-bananas/values-tst.yaml index 7dd90c1..69cddd8 100644 --- a/deploy/helm/go-bananas/values-tst.yaml +++ b/deploy/helm/go-bananas/values-tst.yaml @@ -2,7 +2,8 @@ nameOverride: "" fullnameOverride: "" -replicaCount: 1 +deployment: + replicaCount: 1 image: repository: nexus.urpi.be/docker-hosted/go-bananas @@ -13,6 +14,17 @@ service: type: ClusterIP port: 80 +# Spelled out here rather than inherited from values.yaml, whose default issuer +# points at the development realm: a missing key would otherwise make the +# testing release validate tokens against the development identity provider. +identity: + mode: oidc + oidc: + issuer: https://auth.tst-urpi.be/realms/urpi + clientId: go-bananas + existingSecret: go-bananas-auth + generateSecret: false + ingress: enabled: true className: nginx @@ -20,20 +32,14 @@ ingress: cert-manager.io/cluster-issuer: letsencrypt-staging nginx.ingress.kubernetes.io/ssl-redirect: "true" hosts: - - host: bananas-tst.intern-urpi.be + - host: bananas.tst-urpi.be paths: - path: / pathType: Prefix tls: - secretName: go-bananas-tls hosts: - - bananas-tst.intern-urpi.be - -certManager: - enabled: true - issuerRef: - name: letsencrypt-staging - kind: ClusterIssuer + - bananas.tst-urpi.be resources: limits: diff --git a/deploy/helm/go-bananas/values.yaml b/deploy/helm/go-bananas/values.yaml index c8950b0..1495747 100644 --- a/deploy/helm/go-bananas/values.yaml +++ b/deploy/helm/go-bananas/values.yaml @@ -197,7 +197,7 @@ config: # replicaCount: 1, or a filesystem that permits multiple mounts. persistence: - enabled: true + enabled: false type: persistentVolumeClaim accessMode: ReadWriteOnce size: 2Gi @@ -288,7 +288,7 @@ identity: # issuer: this string must match the `iss` claim in the ID token exactly, # including a trailing slash if the provider emits one. A mismatch here is the # single most common cause of "invalid issuer" at the callback. - issuer: "https://auth-dev.urpi.be/realms/urpi" + issuer: "https://auth.dev-urpi.be/realms/urpi" # clientId is the client identifier registered with the provider. clientId: "go-bananas" diff --git a/docs/deployment.md b/docs/deployment.md index 2c75f48..b1dc9b9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -10,6 +10,7 @@ Running go-bananas, from a laptop to a production cluster. - [Configuration](#configuration) - [Authentication](#authentication) - [Helm](#helm) +- [Local deployment (lcl)](#local-deployment-lcl) - [The production checklist](#the-production-checklist) - [Scaling](#scaling) - [Backup and restore](#backup-and-restore) @@ -127,7 +128,7 @@ is made impossible to construct. ### The shared development realm -The chart defaults to `https://auth-dev.urpi.be/realms/urpi`, so a development cluster +The chart defaults to `https://auth.lcl-urpi.be/realms/urpi`, so a development cluster works with no identity configuration. The realm path is part of the issuer. The value must match the `iss` claim in the ID @@ -260,6 +261,77 @@ helm lint deploy/helm/go-bananas --strict --- +## Local deployment (lcl) + +The `lcl` environment is the developer's own single-node **k3s** cluster used for +local integration testing. It is defined by `deploy/helm/go-bananas/values-lcl.yaml`: + +- `config.environment: development` so the local auth bypass is allowed. +- `identity.mode: dev` so you can sign in without an OIDC provider. +- `image.registry: localhost:5000` so the cluster pulls from a local registry. +- Ingress host `bananas-lcl.intern-urpi.be`, terminated by the local TLS edge. + +All the `go-*` applications share the **`go-insane-lcl`** namespace; go-bananas is a +Helm release named `go-bananas` inside it. + +### Prerequisites + +1. A running local k3s cluster and a registry at `localhost:5000`. +2. `bananas-lcl.intern-urpi.be` resolving to the node (`/etc/hosts`) behind the TLS edge. +3. `docker buildx`, `helm` and `kubectl`. + +> **Kubeconfig caveat.** On this machine the default `local` context goes through a +> Rancher proxy (`https://rancher.urpi.be/k8s/clusters/local`) which can 502. When it +> does, talk to k3s directly instead: `export KUBECONFIG=/etc/rancher/k3s/k3s.yaml`. +> Everything below respects that variable. + +### One-shot build + deploy + verify + +```bash +KUBECONFIG=/etc/rancher/k3s/k3s.yaml ./hack/deploy-lcl.sh +# or, equivalently: +KUBECONFIG=/etc/rancher/k3s/k3s.yaml make deploy-lcl +``` + +This builds a fresh image (with the web admin UI and runtime SDK compiled in), pushes +it to the local registry, runs `helm upgrade --install`, waits for the rollout, and +curls the runtime manifest through the TLS edge. The image tag defaults to a build +timestamp so a rebuild is always re-pulled (`IfNotPresent` would otherwise serve a +stale cached tag). Override it with `LCL_TAG=...`. + +### Step by step + +```bash +make install # Go and Node dependencies +make image-lcl # build + push to localhost:5000/go-bananas: +make helm-template-lcl # sanity-check the rendered manifest +make helm-install-lcl # helm upgrade --install into go-insane-lcl +``` + +`LCL_TAG`, `LCL_REGISTRY` and `LCL_NAMESPACE` are overridable: + +```bash +make deploy-lcl LCL_TAG=v1 LCL_REGISTRY=registry:5000 LCL_NAMESPACE=go-insane-lcl +``` + +### Sign in and verify + +The release uses `dev` authentication, so any user is a local administrator. Open +`https://bananas-lcl.intern-urpi.be/?dev-user=you@example.test`, then: + +```bash +curl -sk https://bananas-lcl.intern-urpi.be/readyz +curl -sk https://bananas-lcl.intern-urpi.be/rt/v1/manifest.json | jq '.themes[].slug' +``` + +### Remove + +```bash +KUBECONFIG=/etc/rancher/k3s/k3s.yaml make helm-uninstall-lcl +``` + +--- + ## The production checklist - [ ] `config.environment: production` @@ -544,4 +616,4 @@ applications. A CDN or browser holding an old version explains it; the runtime manifest's `baseUrl` will show whether a CDN is in the path. **The stylesheet is stale after a publish.** Expected within one `s-maxage` window -(300 s by default). To force it, lower `config.runtimeCacheControl` or purge the CDN. \ No newline at end of file +(300 s by default). To force it, lower `config.runtimeCacheControl` or purge the CDN. diff --git a/docs/development.md b/docs/development.md index 809a817..ad95d72 100644 --- a/docs/development.md +++ b/docs/development.md @@ -26,7 +26,7 @@ make tools # air, golangci-lint, sqlc ## Running ```bash -make dev-all # API on :8080, admin UI on :5173 with a proxy +make dev-all # API on :8080, admin UI on :5173 with a proxy (builds the SDK first) ``` Open . The Vite dev server proxies `/api`, `/rt` and `/auth` @@ -34,11 +34,18 @@ to the Go process, so the session cookie works exactly as it will in production. Same-origin matters: a cross-origin setup would not authenticate at all, and the problem would look like a bug rather than a topology difference. +The admin UI is itself a consumer of the runtime: the header theme selector and the +light/dark toggle are the shipped controls, loaded from `/rt/v1/components.js`, which +the **API** serves from `sdk/dist` (Vite proxies `/rt` to it). `make dev-all` builds +the SDK up front for exactly this reason — an unbuilt `sdk/dist` means those controls +never define and the switcher silently falls back. + Other entry points: ```bash -make dev-api # the API alone, with hot reload -make dev-web # the admin UI alone +make dev # the API with hot reload (air); serves web/dist and sdk/dist, so `make build` first +make dev-api # the API alone, no hot reload; serves the runtime SDK, Vite serves the UI +make dev-web # the admin UI alone (its /api and /rt proxy to :8080) make run # build everything, run the real binary ``` diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 82bff8d..7802f45 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -22,7 +22,7 @@ import ( ) // OIDCProvider implements Resolver against any OpenID Connect provider, -// including the auth-dev.urpi.be realm used for local development. +// including the auth.lcl-urpi.be realm used for local development. // // Everything provider-specific comes from the issuer's discovery document, so // the same code works against Keycloak, Authentik, Zitadel or Google without a diff --git a/internal/config/config.go b/internal/config/config.go index 4d60f7f..7f79d6a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -71,7 +71,7 @@ type Config struct { // AuthMode is oidc, dev, or none. AuthMode string - // OIDCIssuer is the issuer URL, e.g. https://auth-dev.urpi.be/realms/urpi. + // OIDCIssuer is the issuer URL, e.g. https://auth.lcl-urpi.be/realms/urpi. OIDCIssuer string // OIDCClientID is the client identifier registered with the provider. OIDCClientID string diff --git a/internal/httpapi/router_test.go b/internal/httpapi/router_test.go index 35ac366..73a5e42 100644 --- a/internal/httpapi/router_test.go +++ b/internal/httpapi/router_test.go @@ -70,7 +70,7 @@ func newUnauthenticatedRouter(t *testing.T) http.Handler { t.Helper() cfg := testConfig(t) cfg.AuthMode = config.AuthModeOIDC - cfg.OIDCIssuer = "https://auth-dev.urpi.be/realms/urpi" + cfg.OIDCIssuer = "https://auth.lcl-urpi.be/realms/urpi" cfg.OIDCClientID = "go-bananas" cfg.OIDCClientSecret = "test-client-secret" cfg.SessionSecure = false @@ -278,7 +278,7 @@ func TestRuntimeIsReadableCrossOrigin(t *testing.T) { // not testing the policy — it is testing the same-origin path under a // misleading name. req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil) - req.Header.Set("Origin", "https://tell-dev.urpi.be") + req.Header.Set("Origin", "https://tell.lcl-urpi.be") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) @@ -313,7 +313,7 @@ func TestAdminAPIIsNotOpenedByTheRuntimePolicy(t *testing.T) { for _, path := range []string{"/api/v1/session", "/api/v1/themes"} { t.Run(path, func(t *testing.T) { req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil) - req.Header.Set("Origin", "https://tell-dev.urpi.be") + req.Header.Set("Origin", "https://tell.lcl-urpi.be") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) @@ -357,7 +357,7 @@ func TestAdminAPIAllowsAConfiguredOrigin(t *testing.T) { func TestRuntimeAnswersPreflight(t *testing.T) { req := httptest.NewRequestWithContext( context.Background(), http.MethodOptions, "/rt/v1/contract.json", nil) - req.Header.Set("Origin", "https://tell-dev.urpi.be") + req.Header.Set("Origin", "https://tell.lcl-urpi.be") // A real preflight names the method it wants to use. Without this header the // request is not a preflight, and asserting 204 on it would be asserting on a // case no browser ever sends — which is how this test passed against a 405. diff --git a/internal/store/repositories.go b/internal/store/repositories.go index 7e96b22..c529cbe 100644 --- a/internal/store/repositories.go +++ b/internal/store/repositories.go @@ -207,8 +207,8 @@ func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) ([]domain.Aud args []any ) if filter.Actor != "" { - where = append(where, `actor = ?`) - args = append(args, filter.Actor) + where = append(where, `actor_email LIKE ?`) + args = append(args, "%"+filter.Actor+"%") } if filter.Action != "" { where = append(where, `action = ?`) diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json index e71481a..4158b26 100644 --- a/sdk/tsconfig.json +++ b/sdk/tsconfig.json @@ -23,6 +23,7 @@ }, "include": [ "src/**/*.js", + "src/**/*.d.ts", "vite.config.js", "vitest.config.js" ] diff --git a/web/eslint.config.js b/web/eslint.config.js index 89bb40e..c6ca1b3 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -28,7 +28,11 @@ export default tseslint.config( { languageOptions: { parserOptions: { - projectService: true, + // The app build project excludes test files (so the production image build + // never compiles the SDK-coupled integration test). ESLint still needs type + // info for every file it lints, so point it at BOTH projects explicitly + // rather than relying on projectService to discover the test project. + project: ['./tsconfig.app.json', './tsconfig.test.json'], tsconfigRootDir: import.meta.dirname, }, globals: { ...globals.browser, ...globals.es2022 }, diff --git a/web/package.json b/web/package.json index bf51356..4513ccc 100644 --- a/web/package.json +++ b/web/package.json @@ -7,11 +7,11 @@ "license": "SEE LICENSE IN ../LICENSE", "scripts": { "dev": "vite", - "build": "tsc --build && vite build", + "build": "tsc -p tsconfig.app.json && vite build", "preview": "vite preview", "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --fix", - "typecheck": "tsc --build --force", + "typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.test.json --noEmit", "test": "vitest run", "test:watch": "vitest", "test:ui": "vitest --ui", @@ -56,4 +56,4 @@ "engines": { "node": ">=22" } -} \ No newline at end of file +} diff --git a/web/src/components/admin-theme-context.ts b/web/src/components/admin-theme-context.ts index be78fe7..d0c7831 100644 --- a/web/src/components/admin-theme-context.ts +++ b/web/src/components/admin-theme-context.ts @@ -16,6 +16,11 @@ export type AdminThemeValue = { previewSlug: string | null setMode: (mode: AdminThemeMode) => void setPreviewSlug: (slug: string | null) => void + /** + * Sets mode and preview slug atomically. Used by the shipped controls so that + * picking a theme does not race with picking its default appearance. + */ + choose: (choice: { mode: AdminThemeMode; slug: string | null }) => void /** The slugs available to preview, from the runtime manifest. */ available: string[] /** The runtime base URL, for building preview URLs. */ diff --git a/web/src/components/admin-theme.test.tsx b/web/src/components/admin-theme.test.tsx index c118f43..3aa79a8 100644 --- a/web/src/components/admin-theme.test.tsx +++ b/web/src/components/admin-theme.test.tsx @@ -21,11 +21,27 @@ vi.mock('../lib/api', () => ({ resetPreferences: vi.fn(() => Promise.resolve({ selectedTheme: '', selectedAppearance: '' })), })) +// Mock the control loader so the origin it is asked to load from can be asserted +// directly, rather than depending on the loader's module-level singleton promise. +vi.mock('../lib/runtime-controls', () => ({ + loadRuntimeControls: vi.fn(() => new Promise(() => undefined)), +})) + const api = await import('../lib/api') +const runtimeControls = await import('../lib/runtime-controls') const THEME_ATTRIBUTE = 'data-bananas-theme' const APPEARANCE_ATTRIBUTE = 'data-bananas-appearance' +const FULL_ENTRY = { + slug: 'midnight', + name: 'Midnight', + defaultAppearance: 'dark', + version: 1, + css: '/rt/v1/theme.css?slug=midnight', + tokens: '/rt/v1/theme.json?slug=midnight', +} as const + /** A probe that renders the current choice and exposes the setters. */ function Probe() { const { mode, previewSlug, available, setMode, setPreviewSlug } = useAdminTheme() @@ -110,20 +126,26 @@ describe('with a stored preference', () => { // knows that its own chrome is styled by the full variant of a theme. await waitFor(() => expect( - document.querySelector('link[data-bananas-admin="bananas-admin-preview"]')?.getAttribute('href'), + document + .querySelector('link[data-bananas-admin="bananas-admin-preview"]') + ?.getAttribute('href'), ).toBe('/rt/v1/theme.css?slug=midnight&variant=full'), ) }) - // The document attributes belong to the shipped control, so that there is one writer - // for them rather than two that can disagree. This test pins that division of - // responsibility: if the provider starts writing them again, it will fail. - it('leaves the document attributes to the shipped controls', async () => { + // While the shipped controls are not ready — in this file loadRuntimeControls is + // mocked and never resolves — the provider is the only writer, and the preview + // stylesheet is scoped to exactly these attributes. If the provider did not apply + // them, the fallback switcher would change state but repaint nothing, which is the + // "I can't select a theme / light-dark does nothing" bug. (The single-writer handoff, + // where the controls take over once loaded, is covered by the real-controls + // integration test.) + it('applies the document attributes itself while the controls are not ready', async () => { renderProvider() await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark')) await waitFor(() => expect(screen.getByTestId('slug')).toHaveTextContent('midnight')) - expect(document.documentElement.hasAttribute(THEME_ATTRIBUTE)).toBe(false) - expect(document.documentElement.hasAttribute(APPEARANCE_ATTRIBUTE)).toBe(false) + expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('midnight') + expect(document.documentElement.getAttribute(APPEARANCE_ATTRIBUTE)).toBe('dark') }) it('writes the choice back to the API when the mode changes', async () => { @@ -186,3 +208,41 @@ describe('when persisting fails', () => { expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark') }) }) + +// TestTheAdminConsumesItsRuntimeSameOrigin: a manifest (or config) that advertises a +// DIFFERENT absolute origin than the page itself — a CDN in front, a public host that +// is not the one you opened, `localhost` vs `127.0.0.1` — must not make the admin +// fetch its own controls and preview stylesheet from that other origin. The page's CSP +// is `script-src 'self'`, so a cross-origin `