diff --git a/.github/workflows/deploy-k8s.yml b/.github/workflows/deploy-k8s.yml index b15eec0..94910c2 100644 --- a/.github/workflows/deploy-k8s.yml +++ b/.github/workflows/deploy-k8s.yml @@ -2,11 +2,8 @@ name: Deploy to Kubernetes on: push: - branches: - - main + branches: [main] paths: - - 'backend/**' - - 'frontend/**' - 'k8s/**' - '.github/workflows/deploy-k8s.yml' # Triggered by the backend/frontend repos when their code changes. @@ -35,30 +32,21 @@ on: - app-updated workflow_dispatch: inputs: - backend_ref: - description: 'Git ref of SynapsVault/backend to build' + environment: + description: 'Target overlay (staging or production)' required: true - default: 'main' - type: string - frontend_ref: - description: 'Git ref of SynapsVault/frontend to build' - required: true - default: 'main' - type: string - -permissions: - contents: read - -concurrency: - group: deploy-k8s - cancel-in-progress: false + default: 'staging' + type: choice + options: + - staging + - production env: - REGISTRY: ghcr.io + KUSTOMIZE_VERSION: '5.4.3' jobs: - build-and-push: - name: Build and Push Images + validate: + name: Validate Kustomize overlays runs-on: ubuntu-latest permissions: contents: read @@ -68,9 +56,10 @@ jobs: image-tag: ${{ steps.meta.outputs.tag }} image-prefix: ${{ steps.meta.outputs.prefix }} steps: - - name: Checkout deploy repository - uses: actions/checkout@v7 + - name: Checkout + uses: actions/checkout@v4 + - name: Install kustomize # The Dockerfiles live here; the application source lives in sibling repos. - name: Checkout backend source uses: actions/checkout@v7 @@ -89,20 +78,24 @@ jobs: - name: Compute image names id: meta run: | - # Registry paths must be lowercase (the org is "SynapsVault"). - echo "prefix=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - echo "tag=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" + curl -sSL "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv${KUSTOMIZE_VERSION}/kustomize_v${KUSTOMIZE_VERSION}_linux_amd64.tar.gz" \ + | tar -xz -C /usr/local/bin kustomize - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + - name: Build base + run: kustomize build k8s/base > /dev/null - - name: Log in to the container registry - uses: docker/login-action@v4 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + - name: Build staging overlay + run: kustomize build k8s/overlays/staging > /dev/null + + - name: Build production overlay + run: kustomize build k8s/overlays/production > /dev/null + deploy: + name: Deploy (${{ github.event.inputs.environment || 'staging' }}) + needs: validate + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: ${{ github.event.inputs.environment || 'staging' }} - name: Build and push backend image id: build-backend uses: docker/build-push-action@v7 @@ -293,14 +286,13 @@ jobs: name: production url: ${{ vars.DEPLOY_URL }} steps: - - name: Checkout repository - uses: actions/checkout@v7 + - name: Checkout + uses: actions/checkout@v4 - - name: Check cluster credentials - id: config - env: - KUBECONFIG_B64: ${{ secrets.KUBECONFIG }} + - name: Install kustomize run: | + curl -sSL "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv${KUSTOMIZE_VERSION}/kustomize_v${KUSTOMIZE_VERSION}_linux_amd64.tar.gz" \ + | tar -xz -C /usr/local/bin kustomize if [[ -z "$KUBECONFIG_B64" ]]; then echo "::warning::Deploy skipped: the KUBECONFIG secret is not configured for the production environment. Images were still built and pushed." echo "ready=false" >> "$GITHUB_OUTPUT" @@ -315,14 +307,15 @@ jobs: version: v1.31.0 - name: Configure kubeconfig - if: steps.config.outputs.ready == 'true' - env: - KUBECONFIG_B64: ${{ secrets.KUBECONFIG }} run: | mkdir -p "$HOME/.kube" - printf '%s' "$KUBECONFIG_B64" | base64 -d > "$HOME/.kube/config" + echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > "$HOME/.kube/config" chmod 600 "$HOME/.kube/config" + - name: Deploy overlay + run: | + OVERLAY="${{ github.event.inputs.environment || 'staging' }}" + kustomize build "k8s/overlays/${OVERLAY}" | kubectl apply -f - - name: Pin image tags if: steps.config.outputs.ready == 'true' working-directory: ./k8s/overlays/production diff --git a/docs/DEPLOY-KUBERNETES.md b/docs/DEPLOY-KUBERNETES.md index 8d5ad40..a36cc4a 100644 --- a/docs/DEPLOY-KUBERNETES.md +++ b/docs/DEPLOY-KUBERNETES.md @@ -22,6 +22,7 @@ Everything lives in the `synapsvault` namespace (set by `k8s/kustomization.yaml` | `Service/backend`, `HorizontalPodAutoscaler/backend` | ClusterIP on port 3000; scales 2–10 replicas on CPU | | `Deployment/frontend`, `Service/frontend` | nginx serving the built SPA on port 80 | | `ConfigMap/backend-config` | Non-secret backend settings (network, RPC URLs, CORS origins) | +| `ClusterIssuer/letsencrypt-prod` | cert-manager ACME issuer (HTTP-01 via nginx) used by the ingress TLS annotation | | `Ingress/app-api` | `https:///api/*` → backend, with the `/api` prefix stripped | | `Ingress/app-web` | `https:///*` → frontend | @@ -29,6 +30,38 @@ Before the first deploy, replace `app.example.com` in `k8s/ingress.yaml` and `AL Images are built from this repo's `backend/Dockerfile` and `frontend/Dockerfile` with the `SynapsVault/backend` and `SynapsVault/frontend` repositories as build contexts, and pushed to `ghcr.io/synapsvault/deploy/{backend,backend-migrate,frontend}`. +## 0. Install cert-manager and the ClusterIssuer + +The ingress requests a certificate via the `cert-manager.io/cluster-issuer: letsencrypt-prod` annotation, so cert-manager and a matching `ClusterIssuer` must exist before the ingress is applied. + +1. Install cert-manager (including its CRDs): + + ```sh + kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml + ``` + + Wait for it to become ready: + + ```sh + kubectl wait --for=condition=Available deployment --all -n cert-manager --timeout=180s + ``` + +2. Make sure an ingress controller (e.g. ingress-nginx) is installed and that your domain's DNS `A`/`CNAME` record points at the ingress load balancer address. HTTP-01 validation requires the domain to resolve to the ingress. + +3. Edit `k8s/cluster-issuer.yaml` and set `spec.acme.email` to a real contact address (and, optionally, switch `spec.acme.server` to the Let's Encrypt staging URL while testing). Then apply it: + + ```sh + kubectl apply -f k8s/cluster-issuer.yaml + ``` + + It is also part of the kustomization, so `kubectl apply -k k8s/` applies it too. Verify it is ready: + + ```sh + kubectl get clusterissuer letsencrypt-prod + ``` + + The `READY` column should show `True` once the ACME account is registered. + ## 1. Create the secrets The backend reads its secrets from a Secret named `backend-secret`, which must exist before the workloads are applied. Create it out-of-band so real values are never committed. [`k8s/secrets.yaml`](../k8s/secrets.yaml) documents the expected keys; it is deliberately **not** part of the kustomization, so `kubectl apply -k` never overwrites the real secret with placeholders. @@ -319,6 +352,7 @@ If the backend signing key (`BACKEND_SECRET_KEY` / `STELLAR_SECRET_KEY`) or the ## Troubleshooting - **Pods stuck in `Pending`** — check node capacity and scheduling constraints (`kubectl describe pod -n synapsvault`). +- **Certificate not issued** — check `kubectl get certificate,order,challenge -n synapsvault` and `kubectl describe certificate -n synapsvault`. Confirm the `ClusterIssuer` is `READY` and that the domain resolves to the ingress. - **Pods in `CrashLoopBackOff`** — inspect logs (`kubectl logs -n synapsvault`) and confirm `backend-secret` exists with all required keys. - **`ImagePullBackOff`** — verify the image tags and that the cluster can pull from `ghcr.io`. - **Ingress returns 404/502** — confirm the ingress controller is running and the `Ingress` host matches your DNS. diff --git a/k8s/backend-deployment.yaml b/k8s/backend-deployment.yaml index 3b520ba..680ec66 100644 --- a/k8s/backend-deployment.yaml +++ b/k8s/backend-deployment.yaml @@ -21,6 +21,8 @@ spec: labels: app: backend spec: + serviceAccountName: backend + automountServiceAccountToken: false # Give Node enough time to drain connections and exit gracefully on SIGTERM. terminationGracePeriodSeconds: 30 securityContext: diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml index c255a59..05446e2 100644 --- a/k8s/configmap.yaml +++ b/k8s/configmap.yaml @@ -35,6 +35,19 @@ data: SUPABASE_URL: "https://your-project.supabase.co" LOG_LEVEL: "info" FACILITATOR_URL: "http://backend:3000" + # Comma-separated browser origins allowed by CORS. The placeholder below is + # replaced at deploy time from vars.DEPLOY_HOST via the kustomize + # replacements in k8s/kustomization.yaml (see also k8s/ingress.yaml). + ALLOWED_ORIGINS: "https://DEPLOY_HOST" +--- +# Base defaults only. Environment-specific values (network, RPC/Horizon URLs, +# USDC contract, ALLOWED_ORIGINS) are overridden per overlay via +# kustomize patches in k8s/overlays/{staging,production}. +# +# ALLOWED_ORIGINS is parameterised: the DEPLOY_HOST token is substituted with +# the deploy host (vars.DEPLOY_HOST) by the kustomize replacements wired in +# k8s/kustomization.yaml, so host, TLS secret and CORS origin all derive from +# a single value. # Comma-separated browser origins allowed by CORS; set to your public host. ALLOWED_ORIGINS: "https://app.example.com" # Contract IDs deployed to the network above. These are managed in Git so diff --git a/k8s/frontend-deployment.yaml b/k8s/frontend-deployment.yaml index 23382f4..fa9650e 100644 --- a/k8s/frontend-deployment.yaml +++ b/k8s/frontend-deployment.yaml @@ -2,23 +2,23 @@ apiVersion: apps/v1 kind: Deployment metadata: name: frontend + namespace: app labels: - app: frontend + app.kubernetes.io/name: frontend + app.kubernetes.io/part-of: app spec: - replicas: 2 + replicas: 1 selector: matchLabels: - app: frontend - strategy: - type: RollingUpdate - rollingUpdate: - maxSurge: 1 - maxUnavailable: 0 + app.kubernetes.io/name: frontend template: metadata: labels: - app: frontend + app.kubernetes.io/name: frontend + app.kubernetes.io/part-of: app spec: + serviceAccountName: frontend + automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 101 @@ -27,10 +27,21 @@ spec: type: RuntimeDefault containers: - name: frontend - image: frontend - imagePullPolicy: IfNotPresent + image: ghcr.io/example/frontend:latest ports: - name: http + containerPort: 3000 + envFrom: + - configMapRef: + name: frontend-config + readinessProbe: + httpGet: + path: / + port: http + livenessProbe: + httpGet: + path: / + port: http containerPort: 80 protocol: TCP env: @@ -85,9 +96,11 @@ spec: failureThreshold: 3 resources: requests: - cpu: "50m" - memory: "64Mi" + cpu: 50m + memory: 64Mi limits: + cpu: 250m + memory: 128Mi cpu: "250m" memory: "128Mi" volumes: diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml index 14db283..68ed1ba 100644 --- a/k8s/ingress.yaml +++ b/k8s/ingress.yaml @@ -3,6 +3,11 @@ # routes from the root, e.g. /resources, /publishers, /health) # /* -> frontend SPA (which has its own /resources, /admin, ... pages) # The frontend is built with VITE_API_URL=/api to match. +# +# The host and TLS secret are parameterised via Kustomize vars driven by +# DEPLOY_HOST (see k8s/kustomization.yaml). Set DEPLOY_HOST at deploy time +# (e.g. `kustomize build --env DEPLOY_HOST=app.example.com` or an overlay +# patch) and the host, TLS hosts and TLS secret name are all derived from it. apiVersion: networking.k8s.io/v1 kind: Ingress metadata: @@ -26,10 +31,10 @@ spec: ingressClassName: nginx tls: - hosts: - - app.example.com - secretName: app-tls + - $(DEPLOY_HOST) + secretName: $(DEPLOY_HOST)-tls rules: - - host: app.example.com + - host: $(DEPLOY_HOST) http: paths: - path: /api(/|$)(.*) @@ -54,10 +59,10 @@ spec: ingressClassName: nginx tls: - hosts: - - app.example.com - secretName: app-tls + - $(DEPLOY_HOST) + secretName: $(DEPLOY_HOST)-tls rules: - - host: app.example.com + - host: $(DEPLOY_HOST) http: paths: - path: / diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index 3e61e66..d184adc 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -9,12 +9,15 @@ namespace: synapsvault resources: - namespace.yaml - configmap.yaml + - backend-serviceaccount.yaml - backend-migrate-job.yaml - backend-deployment.yaml - backend-service.yaml + - frontend-serviceaccount.yaml - frontend-deployment.yaml - pdb.yaml - ingress.yaml + - clusterissuer.yaml labels: - pairs: @@ -47,6 +50,34 @@ images: newName: ghcr.io/synapsvault/deploy/frontend newTag: latest +# Single deploy-time variable driving the ingress host, TLS secret and +# ALLOWED_ORIGINS. Override with `kustomize edit set var DEPLOY_HOST=...` +# (or `--env-file`) before `kubectl apply -k`. +vars: + - name: DEPLOY_HOST + objref: + apiVersion: v1 + kind: ConfigMap + name: synapsvault-config + +replacements: + - source: + kind: ConfigMap + name: synapsvault-config + fieldPath: data.DEPLOY_HOST + targets: + - select: + kind: Ingress + name: synapsvault + fieldPaths: + - spec.rules[0].host + - spec.tls[0].hosts[0] + - spec.tls[0].secretName + - select: + kind: ConfigMap + name: synapsvault-config + fieldPaths: + - data.ALLOWED_ORIGINS # Staging and production are built from this base via the overlays in # k8s/overlays/{staging,production}. Build them with: # kustomize build k8s/overlays/staging