diff --git a/.env.example b/.env.example index 24c4547..855317e 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.lcl-urpi.be/realms/urpi +BANANAS_OIDC_ISSUER=https://auth-dev.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 +# BUILD_DATE=unknown \ No newline at end of file diff --git a/.forgejo/scripts/deploy-prd.sh b/.forgejo/scripts/deploy-prd.sh deleted file mode 100644 index 8ab0098..0000000 --- a/.forgejo/scripts/deploy-prd.sh +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -NAMESPACE=go-insane-prd -REGISTRY=nexus.urpi.be -CERT_MANAGER_VERSION=v1.19.1 -LOCAL_PATH_VERSION=v0.0.37 -LOCK_NAME=forgejo-prd-deploy-lock - -if [[ $# -lt 4 ]]; then - echo "Usage: $0 [helm args...]" >&2 - exit 2 -fi -CHART=$1 -RELEASE=$2 -IMAGE_NAME=$3 -IMAGE_TAG=$4 -shift 4 - -: "${KUBECONFIG_PRD:?Set the Forgejo secret KUBECONFIG_PRD to the PRD kubeconfig contents}" -: "${NEXUS_USERNAME:?Set the Forgejo secret NEXUS_USERNAME}" -: "${NEXUS_PASSWORD:?Set the Forgejo secret NEXUS_PASSWORD}" - -KUBECONFIG="${RUNNER_TEMP:-/tmp}/kubeconfig-prd-${GITHUB_RUN_ID:-$$}" -umask 077 -printf '%s\n' "$KUBECONFIG_PRD" > "$KUBECONFIG" -export KUBECONFIG -LOCK_ACQUIRED=false -cleanup() { - if [[ "$LOCK_ACQUIRED" == true ]]; then kubectl -n "$NAMESPACE" delete configmap "$LOCK_NAME" --ignore-not-found --wait=false >/dev/null 2>&1 || true; fi - rm -f "$KUBECONFIG" -} -trap cleanup EXIT - -secret_value() { - local name=$1 key=$2 encoded - encoded="$(kubectl -n "$NAMESPACE" get secret "$name" -o go-template="{{index .data \"${key}\"}}" 2>/dev/null || true)" - [[ -z "$encoded" || "$encoded" == "" ]] && return 0 - printf '%s' "$encoded" | base64 -d 2>/dev/null || true -} - -kubectl get --raw=/readyz >/dev/null - -# Create the workload namespace and its Nexus pull credential idempotently. -kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - -for attempt in {1..360}; do - if kubectl -n "$NAMESPACE" create configmap "$LOCK_NAME" --from-literal=owner="${GITHUB_REPOSITORY:-local}:${GITHUB_RUN_ID:-$$}" >/dev/null 2>&1; then LOCK_ACQUIRED=true; break; fi - sleep 5 -done -if [[ "$LOCK_ACQUIRED" != true ]]; then echo "Timed out waiting for another go-insane-prd deploy to release its lock." >&2; exit 1; fi -kubectl -n "$NAMESPACE" create secret docker-registry nexus-registry \ - --docker-server="$REGISTRY" \ - --docker-username="$NEXUS_USERNAME" \ - --docker-password="$NEXUS_PASSWORD" \ - --dry-run=client -o yaml | kubectl apply -f - - -# Stateful charts use the cluster default StorageClass. Install the Rancher local -# path provisioner only when the cluster has no default class at all. -DEFAULT_STORAGE_CLASS="$(kubectl get storageclass -o=jsonpath='{range .items[*]}{.metadata.name}{" "}{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}{"\n"}{end}' | awk '$2 == "true" { print $1; exit }')" -if [[ -z "$DEFAULT_STORAGE_CLASS" ]]; then - if ! kubectl get storageclass local-path >/dev/null 2>&1 || ! kubectl -n local-path-storage get deployment local-path-provisioner >/dev/null 2>&1; then - kubectl apply -f "https://raw.githubusercontent.com/rancher/local-path-provisioner/${LOCAL_PATH_VERSION}/deploy/local-path-storage.yaml" - kubectl -n local-path-storage rollout status deployment/local-path-provisioner --timeout=5m - fi - kubectl annotate storageclass local-path storageclass.kubernetes.io/is-default-class=true --overwrite -fi - -# Applications' production charts request nginx ingress and Let's Encrypt TLS. -# Install only missing cluster prerequisites; leave existing installations intact. -if ! kubectl get ingressclass nginx >/dev/null 2>&1; then - helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx --force-update - helm repo update ingress-nginx - helm 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 -fi - -if ! kubectl get crd clusterissuers.cert-manager.io >/dev/null 2>&1 || ! kubectl -n cert-manager get deployment cert-manager >/dev/null 2>&1; then - kubectl apply --validate=false -f "https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml" - kubectl -n cert-manager rollout status deployment/cert-manager --timeout=5m - kubectl -n cert-manager rollout status deployment/cert-manager-cainjector --timeout=5m - kubectl -n cert-manager rollout status deployment/cert-manager-webhook --timeout=5m -fi - -if ! kubectl get clusterissuer letsencrypt-prod >/dev/null 2>&1; then - kubectl apply -f - <<'ISSUER' -apiVersion: cert-manager.io/v1 -kind: ClusterIssuer -metadata: - name: letsencrypt-prod -spec: - acme: - server: https://acme-v02.api.letsencrypt.org/directory - email: admin@urpi.be - privateKeySecretRef: - name: letsencrypt-prod - solvers: - - http01: - ingress: - class: nginx -ISSUER -fi -kubectl wait --for=condition=Ready clusterissuer/letsencrypt-prod --timeout=2m - -if [[ ! -d "$CHART" ]]; then - echo "Helm chart not found: $CHART" >&2 - exit 1 -fi -if [[ -f "$CHART/Chart.lock" ]]; then helm dependency build "$CHART"; fi -HELM_ARGS=( - --namespace "$NAMESPACE" - --create-namespace -) -if [[ "$RELEASE" != go-guess ]]; then - HELM_ARGS+=(--set-string "image.repository=${REGISTRY}/docker-hosted/${IMAGE_NAME}" --set-string "image.tag=${IMAGE_TAG}") -fi -if [[ -f "$CHART/values-prd.yaml" ]]; then - HELM_ARGS+=(--values "$CHART/values-prd.yaml") -fi -case "$RELEASE" in - go-loose) - if ! kubectl -n "$NAMESPACE" get secret go-loose-runtime >/dev/null 2>&1; then - db_password="$(secret_value go-loose-postgresql postgres-password)" - if [[ -z "$db_password" ]] && kubectl -n "$NAMESPACE" get pvc -o name | grep -Eq 'go-loose.*postgresql|data-go-loose'; then - echo "Existing go-loose database storage has no recoverable password secret; refusing to rotate its credentials." >&2 - exit 1 - fi - [[ -n "$db_password" ]] || db_password="$(openssl rand -hex 24)" - session_secret="$(secret_value go-loose-env GO_LOOSE_SESSION_SECRET)" - [[ -n "$session_secret" ]] || session_secret="$(openssl rand -hex 32)" - kubectl -n "$NAMESPACE" create secret generic go-loose-runtime --from-literal=postgresql-password="$db_password" --from-literal=session-secret="$session_secret" - fi - db_password="$(secret_value go-loose-runtime postgresql-password)" - session_secret="$(secret_value go-loose-runtime session-secret)" - database_url="postgres://goloose:${db_password}@go-loose-postgresql:5432/goloose?sslmode=disable" - HELM_ARGS+=(--set-string "postgresql.auth.password=${db_password}" --set-string "envSecrets.GO_LOOSE_DATABASE_URL=${database_url}" --set-string "envSecrets.GO_LOOSE_SESSION_SECRET=${session_secret}") - ;; - go-mad) - if ! kubectl -n "$NAMESPACE" get secret go-mad-runtime >/dev/null 2>&1; then - db_password="$(secret_value go-mad-postgresql password)" - [[ -n "$db_password" ]] || db_password="$(secret_value go-mad-postgresql postgres-password)" - if [[ -z "$db_password" ]] && kubectl -n "$NAMESPACE" get pvc -o name | grep -Eq 'go-mad.*postgresql|data-go-mad'; then - echo "Existing go-mad database storage has no recoverable password secret; refusing to rotate its credentials." >&2 - exit 1 - fi - [[ -n "$db_password" ]] || db_password="$(openssl rand -hex 24)" - kubectl -n "$NAMESPACE" create secret generic go-mad-runtime --from-literal=postgresql-password="$db_password" --from-literal=jwt-secret="$(openssl rand -hex 32)" - fi - db_password="$(secret_value go-mad-runtime postgresql-password)" - jwt_secret="$(secret_value go-mad-runtime jwt-secret)" - HELM_ARGS+=(--set-string "postgresql.auth.password=${db_password}" --set-string "go-mad.jwtSecret=${jwt_secret}") - ;; - go-first) - if ! kubectl -n "$NAMESPACE" get secret go-first-runtime >/dev/null 2>&1; then - kubectl -n "$NAMESPACE" create secret generic go-first-runtime --from-literal=jwt-secret="$(openssl rand -hex 32)" - fi - ;; - go-places) - : "${GO_LOOSE_JWT_SECRET:?Set Forgejo secret GO_LOOSE_JWT_SECRET to the same signing key used by go-loose}" - kubectl -n "$NAMESPACE" create secret generic go-loose-jwt --from-literal=secret="$GO_LOOSE_JWT_SECRET" --dry-run=client -o yaml | kubectl apply -f - - ;; - go-bananas) - : "${GO_BANANAS_OIDC_CLIENT_SECRET:?Set Forgejo secret GO_BANANAS_OIDC_CLIENT_SECRET}" - session_secret="$(secret_value go-bananas-auth session-secret)" - if [[ -z "$session_secret" ]]; then session_secret="$(openssl rand -hex 32)"; fi - kubectl -n "$NAMESPACE" create secret generic go-bananas-auth --from-literal=session-secret="$session_secret" --from-literal=oidc-client-secret="$GO_BANANAS_OIDC_CLIENT_SECRET" --dry-run=client -o yaml | kubectl apply -f - - ;; -esac -helm upgrade --install "$RELEASE" "$CHART" "${HELM_ARGS[@]}" "$@" --wait --timeout 15m - -kubectl -n "$NAMESPACE" get deployments,statefulsets,pods,services,ingresses diff --git a/.forgejo/workflows/ci-cd.yaml b/.forgejo/workflows/ci-cd.yaml deleted file mode 100644 index c570f55..0000000 --- a/.forgejo/workflows/ci-cd.yaml +++ /dev/null @@ -1,101 +0,0 @@ -name: Build, test, publish, deploy - -on: - push: - pull_request: - workflow_dispatch: - -env: - GO_VERSION: "1.27.1" - NODE_VERSION: "24" - REGISTRY: nexus.urpi.be - IMAGE_NAME: docker-hosted/go-bananas - -jobs: - build-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - cache: true - - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: npm - cache-dependency-path: | - web/package-lock.json - sdk/package-lock.json - - name: Build and test Go server - run: | - go vet ./... - make test-go - make build-go - - name: Build and test admin UI - run: | - npm ci --no-audit --no-fund - npm run lint - npm test - npm run build - working-directory: web - - name: Build and test SDK - run: | - npm ci --no-audit --no-fund - npm run typecheck - npm test - npm run build - working-directory: sdk - - uses: azure/setup-helm@v4 - with: - version: v3.18.6 - - name: Validate production chart - run: helm lint --strict deploy/helm/go-bananas -f deploy/helm/go-bananas/values-prd.yaml - - publish: - if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') - needs: build-test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ secrets.NEXUS_USERNAME }} - password: ${{ secrets.NEXUS_PASSWORD }} - - uses: docker/build-push-action@v6 - with: - context: . - file: Dockerfile - push: true - tags: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - - deploy-prd: - if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') - needs: publish - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: azure/setup-helm@v4 - with: - version: v3.18.6 - - uses: azure/setup-kubectl@v4 - with: - version: v1.32.2 - - name: Bootstrap resources and deploy go-bananas - env: - KUBECONFIG_PRD: ${{ secrets.KUBECONFIG_PRD }} - NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }} - NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }} - GO_BANANAS_OIDC_CLIENT_SECRET: ${{ secrets.GO_BANANAS_OIDC_CLIENT_SECRET }} - run: | - set -Eeuo pipefail - : "${GO_BANANAS_OIDC_CLIENT_SECRET:?Add Forgejo secret GO_BANANAS_OIDC_CLIENT_SECRET}" - bash .forgejo/scripts/deploy-prd.sh deploy/helm/go-bananas go-bananas go-bananas "${{ github.sha }}" \ - --set-string identity.oidc.issuer=https://auth.urpi.be/realms/urpi \ - --set-string identity.oidc.clientId=go-bananas \ - --set-string identity.oidc.existingSecret=go-bananas-auth \ - --set identity.oidc.generateSecret=false diff --git a/Makefile b/Makefile index 6ffcca4..03ecdf1 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/docker-hosted +REGISTRY ?= nexus.urpi.be IMAGE_NAME ?= $(REGISTRY)/$(BINARY) IMAGE_TAG ?= $(VERSION) PLATFORM ?= linux/amd64,linux/arm64 @@ -127,13 +127,12 @@ dev: ## Run the API with hot reload (Ctrl-C to stop) air -c .air.toml .PHONY: dev-api -dev-api: ## Run the API (no SPA); Vite serves the UI, this serves the runtime SDK +dev-api: ## Run only the API server, no frontend @BANANAS_ENVIRONMENT=development \ BANANAS_AUTH_MODE=dev \ BANANAS_HTTP_ADDR=:8080 \ BANANAS_DATABASE_PATH=./data/go-bananas.db \ - BANANAS_STATIC_DIR= \ - BANANAS_SDK_DIR=$(SDK_DIR)/dist \ + BANANAS_STATIC_DIR= BANANAS_SDK_DIR= \ $(GO) run -ldflags "$(LDFLAGS)" $(CMD) .PHONY: dev-web @@ -141,7 +140,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: build-sdk ## Run the API and the frontend dev server together +dev-all: ## 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 & \ @@ -376,7 +375,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.lcl-urpi.be/realms/urpi \ + --set identity.oidc.issuer=https://auth-dev.urpi.be/realms/urpi \ --set identity.oidc.clientId=go-bananas \ --set identity.oidc.generateSecret=false \ --set identity.oidc.existingSecret=go-bananas-oidc \ @@ -393,61 +392,6 @@ 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 6a73923..4a322d9 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.lcl-urpi.be`. See +The chart defaults to the shared development realm at `auth-dev.urpi.be`. See [docs/deployment.md](docs/deployment.md) for the full production checklist. --- @@ -462,9 +462,8 @@ 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. +MIT. \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index e2300a3..c051152 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.lcl-urpi.be is a shared development realm and a +# profile uses the mock because auth-dev.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 f91cffb..cc6c09c 100644 --- a/deploy/dev/values-dev-cluster.yaml +++ b/deploy/dev/values-dev-cluster.yaml @@ -21,8 +21,12 @@ # reproducible rather than "whatever worked when it was deployed". image: - registry: nexus.urpi.be - repository: docker-hosted/go-bananas + # 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 # 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 @@ -35,7 +39,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. @@ -55,11 +59,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 @@ -70,17 +74,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 + replicaCount: 1 \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/NOTES.txt b/deploy/helm/go-bananas/templates/NOTES.txt index 5e26ff4..b4ef892 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 0798d80..d94981e 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 }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/deployment.yaml b/deploy/helm/go-bananas/templates/deployment.yaml index 15df9ff..f42cdb3 100644 --- a/deploy/helm/go-bananas/templates/deployment.yaml +++ b/deploy/helm/go-bananas/templates/deployment.yaml @@ -52,6 +52,9 @@ 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 }} @@ -72,20 +75,19 @@ 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 9af84c8..17c354f 100644 --- a/deploy/helm/go-bananas/values-dev.yaml +++ b/deploy/helm/go-bananas/values-dev.yaml @@ -2,8 +2,7 @@ nameOverride: "" fullnameOverride: "" -deployment: - replicaCount: 1 +replicaCount: 1 image: repository: nexus.urpi.be/docker-hosted/go-bananas @@ -14,16 +13,6 @@ 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 @@ -31,14 +20,20 @@ ingress: cert-manager.io/cluster-issuer: letsencrypt-staging nginx.ingress.kubernetes.io/ssl-redirect: "true" hosts: - - host: bananas.dev-urpi.be + - host: bananas-dev.intern-urpi.be paths: - path: / pathType: Prefix tls: - secretName: go-bananas-tls hosts: - - bananas.dev-urpi.be + - bananas-dev.intern-urpi.be + +certManager: + enabled: true + issuerRef: + name: letsencrypt-staging + kind: ClusterIssuer resources: limits: diff --git a/deploy/helm/go-bananas/values-lcl.yaml b/deploy/helm/go-bananas/values-lcl.yaml index f64695b..366b2da 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-urpi.be by the docker TLS edge. +# Served at https://bananas-lcl.intern-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-urpi.be" + publicBaseUrl: "https://bananas-lcl.intern-urpi.be" identity: mode: dev @@ -33,14 +33,14 @@ ingress: className: traefik annotations: {} hosts: - - host: bananas.lcl-urpi.be + - host: bananas-lcl.intern-urpi.be paths: - path: / pathType: Prefix tls: - secretName: local-dev-cert hosts: - - bananas.lcl-urpi.be + - bananas-lcl.intern-urpi.be resources: limits: diff --git a/deploy/helm/go-bananas/values-prd.yaml b/deploy/helm/go-bananas/values-prd.yaml index ad834de..f86abb3 100644 --- a/deploy/helm/go-bananas/values-prd.yaml +++ b/deploy/helm/go-bananas/values-prd.yaml @@ -1,31 +1,20 @@ -# Production values for go-bananas. Secret values remain in the Kubernetes Secret -# created by the Forgejo deployment job. -global: - imagePullSecrets: - - name: nexus-registry +# PRD values for go-bananas +nameOverride: "" +fullnameOverride: "" -deployment: - replicaCount: 1 +replicaCount: 1 + +imagePullSecrets: + - name: nexus-registry image: - registry: nexus.urpi.be - repository: docker-hosted/go-bananas - tag: latest + repository: nexus.urpi.be/docker-hosted/go-bananas + tag: "0.0.1" pullPolicy: Always -config: - environment: production - publicBaseUrl: https://bananas.urpi.be - -persistence: - enabled: true - type: persistentVolumeClaim - size: 2Gi - service: type: ClusterIP port: 80 - sessionSecure: true ingress: enabled: true @@ -43,17 +32,16 @@ ingress: hosts: - bananas.urpi.be -identity: - mode: oidc - oidc: - issuer: https://auth.urpi.be/realms/urpi - clientId: go-bananas - existingSecret: go-bananas-auth - generateSecret: false +certManager: + enabled: true + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer resources: + limits: + cpu: 500m + memory: 512Mi requests: cpu: 100m - memory: 128Mi - limits: - memory: 512Mi + memory: 256Mi diff --git a/deploy/helm/go-bananas/values-tst.yaml b/deploy/helm/go-bananas/values-tst.yaml index 69cddd8..7dd90c1 100644 --- a/deploy/helm/go-bananas/values-tst.yaml +++ b/deploy/helm/go-bananas/values-tst.yaml @@ -2,8 +2,7 @@ nameOverride: "" fullnameOverride: "" -deployment: - replicaCount: 1 +replicaCount: 1 image: repository: nexus.urpi.be/docker-hosted/go-bananas @@ -14,17 +13,6 @@ 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 @@ -32,14 +20,20 @@ ingress: cert-manager.io/cluster-issuer: letsencrypt-staging nginx.ingress.kubernetes.io/ssl-redirect: "true" hosts: - - host: bananas.tst-urpi.be + - host: bananas-tst.intern-urpi.be paths: - path: / pathType: Prefix tls: - secretName: go-bananas-tls hosts: - - bananas.tst-urpi.be + - bananas-tst.intern-urpi.be + +certManager: + enabled: true + issuerRef: + name: letsencrypt-staging + kind: ClusterIssuer resources: limits: diff --git a/deploy/helm/go-bananas/values.yaml b/deploy/helm/go-bananas/values.yaml index 1495747..c8950b0 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: false + enabled: true 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 b1dc9b9..2c75f48 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -10,7 +10,6 @@ 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) @@ -128,7 +127,7 @@ is made impossible to construct. ### The shared development realm -The chart defaults to `https://auth.lcl-urpi.be/realms/urpi`, so a development cluster +The chart defaults to `https://auth-dev.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 @@ -261,77 +260,6 @@ 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` @@ -616,4 +544,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. +(300 s by default). To force it, lower `config.runtimeCacheControl` or purge the CDN. \ No newline at end of file diff --git a/docs/development.md b/docs/development.md index ad95d72..809a817 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 (builds the SDK first) +make dev-all # API on :8080, admin UI on :5173 with a proxy ``` Open . The Vite dev server proxies `/api`, `/rt` and `/auth` @@ -34,18 +34,11 @@ 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 # 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 dev-api # the API alone, with hot reload +make dev-web # the admin UI alone make run # build everything, run the real binary ``` diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 7802f45..82bff8d 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.lcl-urpi.be realm used for local development. +// including the auth-dev.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 7f79d6a..4d60f7f 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.lcl-urpi.be/realms/urpi. + // OIDCIssuer is the issuer URL, e.g. https://auth-dev.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 73a5e42..35ac366 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.lcl-urpi.be/realms/urpi" + cfg.OIDCIssuer = "https://auth-dev.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.lcl-urpi.be") + req.Header.Set("Origin", "https://tell-dev.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.lcl-urpi.be") + req.Header.Set("Origin", "https://tell-dev.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.lcl-urpi.be") + req.Header.Set("Origin", "https://tell-dev.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 c529cbe..7e96b22 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_email LIKE ?`) - args = append(args, "%"+filter.Actor+"%") + where = append(where, `actor = ?`) + args = append(args, filter.Actor) } if filter.Action != "" { where = append(where, `action = ?`) diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json index 4158b26..e71481a 100644 --- a/sdk/tsconfig.json +++ b/sdk/tsconfig.json @@ -23,7 +23,6 @@ }, "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 c6ca1b3..89bb40e 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -28,11 +28,7 @@ export default tseslint.config( { languageOptions: { parserOptions: { - // 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'], + projectService: true, tsconfigRootDir: import.meta.dirname, }, globals: { ...globals.browser, ...globals.es2022 }, diff --git a/web/package.json b/web/package.json index 4513ccc..bf51356 100644 --- a/web/package.json +++ b/web/package.json @@ -7,11 +7,11 @@ "license": "SEE LICENSE IN ../LICENSE", "scripts": { "dev": "vite", - "build": "tsc -p tsconfig.app.json && vite build", + "build": "tsc --build && vite build", "preview": "vite preview", "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --fix", - "typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.test.json --noEmit", + "typecheck": "tsc --build --force", "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 d0c7831..be78fe7 100644 --- a/web/src/components/admin-theme-context.ts +++ b/web/src/components/admin-theme-context.ts @@ -16,11 +16,6 @@ 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 3aa79a8..c118f43 100644 --- a/web/src/components/admin-theme.test.tsx +++ b/web/src/components/admin-theme.test.tsx @@ -21,27 +21,11 @@ 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() @@ -126,26 +110,20 @@ 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'), ) }) - // 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 () => { + // 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 () => { renderProvider() await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark')) await waitFor(() => expect(screen.getByTestId('slug')).toHaveTextContent('midnight')) - expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('midnight') - expect(document.documentElement.getAttribute(APPEARANCE_ATTRIBUTE)).toBe('dark') + expect(document.documentElement.hasAttribute(THEME_ATTRIBUTE)).toBe(false) + expect(document.documentElement.hasAttribute(APPEARANCE_ATTRIBUTE)).toBe(false) }) it('writes the choice back to the API when the mode changes', async () => { @@ -208,41 +186,3 @@ 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 `