diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index c9a825c7..0404e848 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -5,6 +5,7 @@ on: branches: [ "main" ] pull_request: branches: [ "main" ] + workflow_dispatch: jobs: lint: @@ -83,11 +84,50 @@ jobs: - name: Run tests (pytest) run: uv run pytest -m "not slow" + manifests: + name: Kubernetes manifests + runs-on: ubuntu-latest + env: + KUSTOMIZE_VERSION: "5.8.1" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pinned kustomize + run: | + curl -fsSL "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 + kustomize version + + - name: Render base + run: kustomize build kubernetes/base > /tmp/base.yaml + + - name: Render ArgoCD test overlay + run: kustomize build kubernetes/overlays/minikube-argocd > /tmp/minikube-argocd.yaml + + # overlays/home joins once its sealed/ files exist (requires the home + # cluster's sealed-secrets controller key). + + - name: Assert sync-wave present on SealedSecrets + run: | + python3 - <<'EOF' + import sys, yaml + docs = list(yaml.safe_load_all(open("/tmp/minikube-argocd.yaml"))) + sealed = [d for d in docs if d and d.get("kind") == "SealedSecret"] + assert sealed, "no SealedSecrets rendered" + for d in sealed: + wave = d["metadata"].get("annotations", {}).get("argocd.argoproj.io/sync-wave") + assert wave == "-3", f"{d['metadata']['name']}: sync-wave={wave!r}, expected '-3'" + print(f"OK: {len(sealed)} SealedSecrets at wave -3") + EOF + publish: name: Publish app image to GHCR runs-on: ubuntu-latest - needs: [test, lint] - if: github.event_name == 'push' + needs: [test, lint, manifests] + # workflow_dispatch allows publishing a sha- tag from a feature branch + # (e.g. to test the branch on minikube before merge). + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' permissions: contents: read packages: write @@ -105,12 +145,86 @@ jobs: - name: Set lowercase owner run: echo "OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV + - name: Set image tags + # The prod tag moves ONLY on main: Watchtower deploys whatever prod + # points at, so a branch dispatch must never touch it. + run: | + TAGS="ghcr.io/${OWNER}/ahc-app:sha-${{ github.sha }}" + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + TAGS="${TAGS},ghcr.io/${OWNER}/ahc-app:prod" + fi + echo "TAGS=${TAGS}" >> $GITHUB_ENV + - name: Build and push ahc-app uses: docker/build-push-action@v6 with: context: . file: docker/Dockerfile-app push: true - tags: | - ghcr.io/${{ env.OWNER }}/ahc-app:prod - ghcr.io/${{ env.OWNER }}/ahc-app:sha-${{ github.sha }} + tags: ${{ env.TAGS }} + + bump: + name: Bump image tag in home overlay + runs-on: ubuntu-latest + needs: publish + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: write + concurrency: + group: ahc-home-image-bump + cancel-in-progress: false + env: + KUSTOMIZE_VERSION: "5.8.1" + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main + + - name: Install pinned kustomize + run: | + curl -fsSL "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 + kustomize version + + - name: Set lowercase owner + run: echo "OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV + + - name: Set image tag in home overlay + run: | + cd kubernetes/overlays/home + kustomize edit set image "ghcr.io/${OWNER}/ahc-app=ghcr.io/${OWNER}/ahc-app:sha-${{ github.sha }}" + + - name: Commit and push + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add kubernetes/overlays/home/kustomization.yaml + if git diff --cached --quiet; then + echo "No image tag change to commit." + exit 0 + fi + git commit -m "chore(deploy): bump ahc-app to sha-${{ github.sha }} [skip ci]" + git push + + deploy-workflow: + name: Submit deploy-smoke Argo Workflow + runs-on: ubuntu-latest + needs: bump + # Self-disabled until an Argo Server is reachable from CI (self-hosted + # runner or tunnel). ArgoCD auto-sync remains the primary deploy path. + if: github.event_name == 'push' && vars.ARGO_SERVER_URL != '' + steps: + - name: Submit workflow via Argo Server API + run: | + curl -fsS -X POST \ + -H "Authorization: Bearer ${{ secrets.ARGO_TOKEN }}" \ + -H "Content-Type: application/json" \ + "${{ vars.ARGO_SERVER_URL }}/api/v1/workflows/argo/submit" \ + -d '{ + "resourceKind": "WorkflowTemplate", + "resourceName": "ahc-deploy-smoke", + "submitOptions": { + "parameters": ["image-tag=sha-${{ github.sha }}"] + } + }' diff --git a/.gitignore b/.gitignore index 044c3dd3..9e3a5d2e 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,10 @@ CLAUDE.md /tars/ /kubernetes/**/*.tar secret.yaml +# Filled plain Secrets (templates stay tracked; SealedSecrets in sealed/ stay tracked) +kubernetes/overlays/*/secrets/*.yaml +# Fetched sealed-secrets controller certificates +*.pem # Volumes /db/ diff --git a/README.md b/README.md index 2283c456..b2276de6 100644 --- a/README.md +++ b/README.md @@ -81,11 +81,16 @@ The stack exposes: Django app on `:8000`, Flower (Celery monitor) on `:5555`. uv run python manage.py runserver ``` -### Kubernetes Deploy (alternative) +### Kubernetes Deploy (GitOps via ArgoCD) -An alternative to Docker Compose for production-like environments. -See [`kubernetes/`](kubernetes/) for kustomization files and secret templates. -Build and load images, then apply with `kubectl apply -k kubernetes/`. +Kubernetes manifests live in `kubernetes/` as a kustomize base with three overlays +(`minikube-local` for a plain `kubectl apply -k` dev loop, `minikube-argocd` for the GitOps +rehearsal, `home` for the production k3s cluster synced by ArgoCD). Images are pulled from +GHCR; the deployed tag is committed to git by CI. + +- Operational runbook (local run, sealing secrets, rehearsal, home bootstrap, backups): + [`kubernetes/README.md`](kubernetes/README.md) +- Architecture decision record: [`doc/13_adr_gitops_argocd.md`](doc/13_adr_gitops_argocd.md) ## Testing diff --git a/argocd/ahc-home.yaml b/argocd/ahc-home.yaml new file mode 100644 index 00000000..020d2d59 --- /dev/null +++ b/argocd/ahc-home.yaml @@ -0,0 +1,26 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: ahc-home + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/Cybernetic-Ransomware/Animals_Healthcare_Application.git + targetRevision: main + path: kubernetes/overlays/home + destination: + server: https://kubernetes.default.svc + namespace: ahc + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + retry: + limit: 3 + backoff: + duration: 30s + factor: 2 + maxDuration: 5m diff --git a/argocd/ahc-minikube-test.yaml b/argocd/ahc-minikube-test.yaml new file mode 100644 index 00000000..e1f26029 --- /dev/null +++ b/argocd/ahc-minikube-test.yaml @@ -0,0 +1,20 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: ahc-minikube-test + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/Cybernetic-Ransomware/Animals_Healthcare_Application.git + targetRevision: feat/argocd + path: kubernetes/overlays/minikube-argocd + destination: + server: https://kubernetes.default.svc + namespace: ahc + # Deliberately no automated sync policy: syncs are manual, without prune + # and without selfHeal, until drift/prune/sealed-secret-recovery tests + # pass on minikube. Automation belongs to ahc-home after the merge. + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/argocd/ahc-workflows.yaml b/argocd/ahc-workflows.yaml new file mode 100644 index 00000000..01731330 --- /dev/null +++ b/argocd/ahc-workflows.yaml @@ -0,0 +1,20 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: ahc-workflows + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/Cybernetic-Ransomware/Animals_Healthcare_Application.git + targetRevision: main + path: workflows + destination: + server: https://kubernetes.default.svc + namespace: argo + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/argocd/argo-workflows.yaml b/argocd/argo-workflows.yaml new file mode 100644 index 00000000..9644f870 --- /dev/null +++ b/argocd/argo-workflows.yaml @@ -0,0 +1,26 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: argo-workflows + namespace: argocd +spec: + project: default + source: + repoURL: https://argoproj.github.io/argo-helm + chart: argo-workflows + targetRevision: 0.45.11 + helm: + values: | + server: + # Homelab-grade auth: the server acts with its own service account. + authModes: + - server + destination: + server: https://kubernetes.default.svc + namespace: argo + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/argocd/sealed-secrets.yaml b/argocd/sealed-secrets.yaml new file mode 100644 index 00000000..ebaa99a3 --- /dev/null +++ b/argocd/sealed-secrets.yaml @@ -0,0 +1,22 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: sealed-secrets + namespace: argocd +spec: + project: default + source: + repoURL: https://bitnami-labs.github.io/sealed-secrets + chart: sealed-secrets + targetRevision: 2.17.3 + helm: + values: | + # Default name expected by the kubeseal CLI discovery. + fullnameOverride: sealed-secrets-controller + destination: + server: https://kubernetes.default.svc + namespace: kube-system + syncPolicy: + automated: + prune: true + selfHeal: true diff --git a/doc/13_adr_gitops_argocd.md b/doc/13_adr_gitops_argocd.md new file mode 100644 index 00000000..553be8e8 --- /dev/null +++ b/doc/13_adr_gitops_argocd.md @@ -0,0 +1,93 @@ +## GitOps deployment with ArgoCD + +### Date: +`2026-07-16` + +### Status +In-building + +### Context +Production deployment is pull-based Watchtower polling the `prod` image tag on a docker-compose stack. +The `kubernetes/` manifests were a leftover from a local minikube workflow: `imagePullPolicy: Never`, +image tarballs committed under `kubernetes/tars/`, no probes, no namespace, broken Service selectors, +ephemeral Postgres storage, plaintext secret files (git-ignored) and no way to run Django migrations +inside the cluster. Deploying that state through any GitOps tool would faithfully reproduce every bug. + +The goal is a declarative deployment where git is the single source of truth, the target being a +single-node k3s cluster on the home server, with local minikube used for rehearsal. + +### Decision +1. **ArgoCD** syncs `kubernetes/overlays/home` from `main`. CI never talks to the cluster; it pushes + the image to GHCR and commits a kustomize image-tag bump (`sha-`) to the home overlay, + serialized by a GitHub Actions concurrency group. ArgoCD picks up the git diff. +2. **Kustomize layout**: `kubernetes/base` plus three overlays: + - `minikube-local` — plain git-ignored Secrets, `kubectl apply -k` only (never an ArgoCD source, + because ArgoCD checks out the repo and the ignored files are absent there); + - `minikube-argocd` — SealedSecrets encrypted for the local minikube controller, used by the + manual-sync `ahc-minikube-test` Application to rehearse the full GitOps loop; + - `home` — SealedSecrets encrypted for the home cluster, CI-managed image tag, backups, smoke test. +3. **Sync phasing**: ArgoCD orders resources by phase first and only then by wave, so a PreSync + migration hook would run before the databases exist on a fresh cluster. Migrations and CouchDB + database creation are therefore **Sync-phase hooks at wave -1**, after ConfigMaps/Secrets/PVCs + (wave -3) and the data stores (wave -2), before the app workloads (wave 0) and Ingress (wave 1). + The smoke test is PostSync. Outside ArgoCD the jobs guard themselves with wait initContainers. +4. **Secrets**: Bitnami **Sealed Secrets**. Only true secrets are encrypted (`ahc-app-secrets`, + `postgres-credentials`, `couchdb-credentials`, `flower-secrets`); everything else lives in the + `ahc-app-config` ConfigMap for readable diffs. Credentials are never duplicated across secrets — + the app maps `DB_*` variables from `postgres-credentials` via `secretKeyRef`. A SealedSecret is + bound to one controller's private key: minikube and home have separate `sealed/` directories. +5. **Probes**: split endpoints. `/livez` has no dependencies (liveness + startup), `/readyz` runs + `SELECT 1` against Postgres (readiness). CouchDB is deliberately outside `/readyz`: its outage + degrades attachments, not the whole application. +6. **Data stores**: StatefulSets with volumeClaimTemplates. Postgres 18 mounts the volume at + `/var/lib/postgresql` (the image keeps PGDATA in a versioned subdirectory since 18). Application + file storage gets its own PVCs: `app-media-pvc` (upload dirs mounted via subPath so committed + icons stay visible) and `app-private-pvc` shared by web and the Celery worker (offline snapshots). +7. **Migrations as a Job**, `makemigrations` removed from every deploy path; `collectstatic` runs in + a web-pod initContainer writing to an emptyDir shared with gunicorn (ManifestStaticFilesStorage). +8. **CI → cluster reachability**: GitHub-hosted runners cannot reach the home server, so the deploy + signal is ArgoCD polling git (option A). The `ahc-deploy-smoke` Argo WorkflowTemplate is committed + for manual runs, and the CI submit step is gated on `vars.ARGO_SERVER_URL` (self-disabled until a + self-hosted runner or tunnel exists). The workflow first waits until the Deployment references + the expected `image-tag` — otherwise a CI-triggered run could bless a stale rollout that ArgoCD + has not synced yet. +9. **Data-loss guards**: the `ahc` Namespace carries `Prune=confirm,Delete=confirm` and the data + PVCs (`app-media-pvc`, `app-private-pvc`, `backup-data`) carry `Prune=false,Delete=false`, so + automated prune cannot silently remove them. The app image runs as uid 1000, and hostPath-backed + provisioners ignore `fsGroup`, so web/celery pods run a root `prepare-storage` initContainer + (mkdir + guarded chown) before the app touches the volumes. + +Rejected alternatives: Helm (kustomize already in place, no templating need), ArgoCD Image Updater +(extra controller; CI commit is simpler and auditable), External Secrets Operator (requires an +external secret store), SOPS/KSOPS (requires an ArgoCD plugin; kubeseal is plain kubectl-side). + +### Consequences +- Git history becomes the deployment history; rollback is `git revert` of a bump commit. +- The Watchtower compose stack keeps running during the transition. Two live Celery beat schedulers + would duplicate Discord notifications, so the home overlay pins `celery-beat` to 0 replicas until + cutover. +- The first sync starts with **empty databases** — migrating data from the compose stack is a + separate manual dump/restore operation. +- Backups: nightly `pg_dump`, a CouchDB **diagnostic export** (`_all_docs`; a real backup is one-way + replication or a PVC snapshot — follow-up), and tar archives of the file PVCs. All land on a PVC on + the same disk, which protects against user error, not disk failure; shipping copies off-host + (restic/rclone to a NAS) is a post-testing follow-up. +- Application PVCs are ReadWriteOnce, which is fine on a single node; a multi-node cluster would need + RWX or object storage. +- Losing the sealed-secrets controller key (cluster rebuild) invalidates all SealedSecrets; the + git-ignored plain files (kept in a password manager) are the re-sealing source. + +### Keywords +- ArgoCD, +- GitOps, +- kustomize, +- sealed-secrets, +- sync-wave, +- k3s. + +### Links +- [ADR-08](08_adr_databases.md) — database responsibilities (PostgreSQL / CouchDB / Redis). +- [ADR-12](12_adr_turso_offline_snapshots.md) — offline snapshots stored under PRIVATE_STORAGE_ROOT. +- [kubernetes/README.md](../kubernetes/README.md) — operational runbook. +- [ArgoCD sync phases and waves](https://argo-cd.readthedocs.io/en/stable/user-guide/sync-waves/). +- [Sealed Secrets](https://github.com/bitnami-labs/sealed-secrets). diff --git a/docker/Dockerfile-app b/docker/Dockerfile-app index feb54143..5e2b854e 100644 --- a/docker/Dockerfile-app +++ b/docker/Dockerfile-app @@ -21,3 +21,6 @@ ENV PATH="/opt/venv/bin:$PATH" \ COPY --chown=appuser:appuser . . USER appuser + +EXPOSE 8000 +CMD ["gunicorn", "ahc.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"] diff --git a/kubernetes/README.md b/kubernetes/README.md new file mode 100644 index 00000000..2e82f71e --- /dev/null +++ b/kubernetes/README.md @@ -0,0 +1,181 @@ +# Kubernetes deployment — ops runbook + +GitOps layout for ArgoCD (see [ADR-13](../doc/13_adr_gitops_argocd.md)). All app images come from +`ghcr.io/cybernetic-ransomware/ahc-app`; the tag is rewritten per overlay. + +## Layout + +| Path | Purpose | Secrets | Applied by | +|---|---|---|---| +| `base/` | all workloads, config, PVCs, jobs | none (referenced only) | — | +| `overlays/minikube-local/` | local dev loop | plain Secrets, git-ignored | `kubectl apply -k` only — **never ArgoCD** | +| `overlays/minikube-argocd/` | GitOps rehearsal | SealedSecrets (minikube key) | `argocd/ahc-minikube-test.yaml`, manual sync | +| `overlays/home/` | production (k3s) | SealedSecrets (home key) | `argocd/ahc-home.yaml`, auto sync from `main` | + +Sync waves: `-3` ConfigMap/Secrets/PVCs → `-2` Postgres/CouchDB/Redis → `-1` Sync hooks +(`ahc-migrate`, `couchdb-init`) → `0` web/celery/beat/flower → `1` Ingress → PostSync smoke test. +Migrations are a **Sync-phase** hook (not PreSync): ArgoCD orders by phase before wave, and PreSync +would run before the databases exist on a fresh cluster. + +## Local run (minikube-local) + +```powershell +# 1. Fill secrets from templates (once); the filled .yaml files are git-ignored +Copy-Item kubernetes/overlays/minikube-local/secrets/app-secret.yaml.template ` + kubernetes/overlays/minikube-local/secrets/app-secret.yaml +# ...same for postgres/couchdb/flower; generate SECRET_KEY with: +uv run python -c "import secrets; print(secrets.token_urlsafe(50))" + +# 2. Render check, then apply +kubectl kustomize kubernetes/overlays/minikube-local +kubectl apply -k kubernetes/overlays/minikube-local + +# 3. Watch +kubectl -n ahc get pods -w +kubectl -n ahc wait --for=condition=complete job/ahc-migrate --timeout=300s +kubectl -n ahc rollout status deploy/ahc-app-backend --timeout=300s + +# 4. Smoke +kubectl -n ahc port-forward svc/ahc-app-backend-service 18000:8000 +curl.exe -f -H "Host: localhost" http://127.0.0.1:18000/livez +curl.exe -f -H "Host: localhost" http://127.0.0.1:18000/readyz +``` + +**Job immutability caveat:** a plain re-apply after an image-tag change fails on the immutable Job +spec. Delete the hook jobs first (ArgoCD's `BeforeHookCreation` policy does this automatically): + +```powershell +kubectl -n ahc delete job ahc-migrate couchdb-init --ignore-not-found +kubectl apply -k kubernetes/overlays/minikube-local +``` + +**Local image iteration:** the GHCR `prod` tag must contain the gunicorn CMD and health endpoints. +To test unmerged code, build straight into minikube and temporarily switch the overlay tag +(never commit `newTag: dev`): + +```powershell +minikube image build -t ghcr.io/cybernetic-ransomware/ahc-app:dev -f docker/Dockerfile-app . +# set newTag: dev in overlays/minikube-local/kustomization.yaml, apply, revert before committing +``` + +## Sealing secrets (kubeseal) + +Install once: `winget install kubeseal`. A SealedSecret decrypts only on the cluster whose +controller key encrypted it — minikube and home have separate `sealed/` directories, sealed from +the same plain files (keep an off-repo copy of those in a password manager). + +```powershell +# against the target cluster context: +kubeseal --controller-namespace kube-system --fetch-cert > cluster.pem # *.pem is git-ignored +Get-Content kubernetes/overlays/minikube-local/secrets/app-secret.yaml | + kubeseal --cert cluster.pem --format yaml | + Set-Content kubernetes/overlays//sealed/app-sealedsecret.yaml +# repeat for postgres-secret, couchdb-secret, flower-secret +``` + +If the cluster is rebuilt, the controller key is gone: re-seal everything from the plain files. + +Note: kubeseal copies the input Secret's annotations into `spec.template.metadata` (the future +unsealed Secret), not onto the SealedSecret object itself. The overlays therefore carry a kustomize +patch stamping `argocd.argoproj.io/sync-wave: "-3"` on every SealedSecret — without it they would +sync at wave 0, after the databases that need them (found the hard way during the minikube +rehearsal). + +## GitOps rehearsal on minikube (minikube-argocd) + +```powershell +kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.27.3/controller.yaml +# seal the four secrets into overlays/minikube-argocd/sealed/ (see above), commit them +kubectl create namespace argocd --dry-run=client -o yaml | kubectl apply -f - +# Pinned to the exact version this runbook was rehearsed with — a floating +# "stable" URL would change behavior without any change in this repo. +# --server-side is required: the applicationsets CRD exceeds the 256 KiB +# last-applied-configuration annotation limit of client-side apply. +$ArgoCdVersion = "v3.4.5" +kubectl apply -n argocd --server-side --force-conflicts -f "https://raw.githubusercontent.com/argoproj/argo-cd/$ArgoCdVersion/manifests/install.yaml" +kubectl apply -f argocd/ahc-minikube-test.yaml +``` + +Triggering a sync without the argocd CLI (equivalent of the Sync button; setting the +`operation` field is the documented kubectl-level interface): + +```powershell +kubectl -n argocd patch application ahc-minikube-test --type merge -p '{"operation":{"initiatedBy":{"username":"manual"},"sync":{"revision":"","syncStrategy":{"hook":{}}}}}' +``` + +A sync stuck waiting on resources that can never become healthy will not stop when the +`operation` field is removed. The preferred method is `argocd app terminate-op +ahc-minikube-test`. Without the argocd CLI there is an **emergency workaround**, tested on the +pinned version above — note it reaches into the controller's internal state, not a public +interface (the Application CRD has no status subresource, so it patches the main object): + +```powershell +kubectl -n argocd patch application ahc-minikube-test --type merge -p '{"status":{"operationState":{"phase":"Terminating"}}}' +``` + +Sync manually from the ArgoCD UI/CLI and verify waves and hooks. Before enabling automation on +home, pass the drift tests: `kubectl edit` a resource → diff shows drift; delete a Deployment → +re-sync restores it; delete a Secret → controller re-creates it from the SealedSecret. + +**Testing branch code through ArgoCD (the honest path):** manifests come from the branch, but +`newTag: prod` points at an image built from an older `main` — a hybrid test proves nothing. +Publish and pin the branch image first: + +1. `gh workflow run "AHC CI|CD" --ref ` (workflow_dispatch publishes `sha-` + without moving `prod`). +2. Wait for the publish job, note the `sha-` tag. +3. Set it in `overlays/minikube-argocd/kustomization.yaml` (`newTag: sha-`). +4. Commit and push to the branch — ArgoCD only sees what is in git. + +Revert the pinned tag before merging. The `minikube image build` + `newTag: dev` shortcut from the +local-run section only applies to `minikube-local` (kubectl path), not to ArgoCD. + +## Home cluster bootstrap (first time) + +1. Install k3s (keeps default Traefik ingress and local-path default StorageClass). +2. Install ArgoCD pinned to the rehearsed version (see the rehearsal section for the exact + command, the version variable and why `--server-side`). +3. `kubectl apply -f argocd/sealed-secrets.yaml` and wait for the controller. +4. Seal the four secrets against the home cluster into `overlays/home/sealed/`, commit to `main`. +5. Replace `ahc.example.home` in `overlays/home/{ingress-patch,configmap-patch}.yaml` with the real + domain, commit. +6. `kubectl apply -f argocd/ahc-home.yaml` — first sync starts with **empty databases**; migrating + data from the compose stack is a manual dump/restore. +7. Cutover note: `celery-beat` is pinned to 0 replicas in the home overlay until the + compose/Watchtower stack is retired (two beats = duplicate Discord notifications). + +## CI / image flow + +`django.yml` publishes `ghcr.io//ahc-app:prod` + `:sha-`, then the `bump` job commits +`kustomize edit set image ...:sha-` to `overlays/home/` (`[skip ci]`, serialized by a +concurrency group). ArgoCD polls `main` and syncs. The `deploy-workflow` job (Argo Workflows submit) +stays disabled until `vars.ARGO_SERVER_URL` is set — requires a self-hosted runner or tunnel. +The `workflows/` directory is synced by the `ahc-workflows` Application (`argocd/ahc-workflows.yaml`); +without ArgoCD apply it manually with `kubectl apply -f workflows/`. Manual workflow run: +`argo submit --from workflowtemplate/ahc-deploy-smoke -n argo`. + +If GHCR packages are private, add a `dockerconfigjson` pull secret (sealed for home) and +`imagePullSecrets:` on every pod spec including the hook jobs. + +## Backups (home overlay) + +| CronJob | Schedule | What | Retention | +|---|---|---|---| +| `pg-backup` | 03:00 | `pg_dump -Fc` to `backup-data` PVC | 14 days | +| `couchdb-export` | 03:30 | `_all_docs` JSON dump — **diagnostic export, not a full backup** | 14 days | +| `files-backup` | 04:00 | tar of `app-media-pvc` + `app-private-pvc` | 14 days | + +Restore: `pg_restore -h postgres-service -U $POSTGRES_USER -d $POSTGRES_DB /backup/pg-.dump`; +CouchDB — re-PUT documents from the export or replicate back from a second instance; files — +untar into the PVCs. + +Limitations (deliberate, documented): backups land on the same disk as the data (protects against +user error, not disk failure) and the CouchDB export is not attachment-complete. Follow-ups after +the testing phase: one-way CouchDB replication or PVC snapshots, and shipping copies off-host with +restic/rclone to a NAS. + +## Credential hygiene + +The legacy plaintext secret files contained a live Discord token and Mailtrap credentials — +**rotate both** before the first exposed deployment, and generate a fresh `SECRET_KEY` for home +(do not reuse the minikube one). diff --git a/kubernetes/backend/web/deployment.yaml b/kubernetes/backend/web/deployment.yaml deleted file mode 100644 index 7eef7139..00000000 --- a/kubernetes/backend/web/deployment.yaml +++ /dev/null @@ -1,37 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: ahc-app-backend - labels: - app: ahc_app_backend -spec: - replicas: 1 - selector: - matchLabels: - app: ahc_app_backend - template: - metadata: - name: ahc-app-backend - labels: - app: ahc_app_backend - spec: - containers: - - name: ahc-app-backend - image: ahc-app:latest - imagePullPolicy: Never - env: - - name: PYTHONUNBUFFERED - value: "1" - - name: PYTHONPATH - value: "/app" - envFrom: - - secretRef: - name: web-secrets - ports: - - containerPort: 8000 - protocol: TCP - resources: - limits: - memory: "256Mi" - cpu: "500m" - restartPolicy: Always diff --git a/kubernetes/backend/web/kustomization.yaml b/kubernetes/backend/web/kustomization.yaml deleted file mode 100644 index 46986f65..00000000 --- a/kubernetes/backend/web/kustomization.yaml +++ /dev/null @@ -1,4 +0,0 @@ -resources: - - deployment.yaml - - secret.yaml - - service.yaml diff --git a/kubernetes/backend/web/secret.yaml.template b/kubernetes/backend/web/secret.yaml.template deleted file mode 100644 index 9b64c580..00000000 --- a/kubernetes/backend/web/secret.yaml.template +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: web-secrets - -stringData: - DB_USER: "..." - DB_PASSWORD: "..." - - EMAIL_HOST_USER: "..." - EMAIL_HOST_PASSWORD: "..." - - DISCORD_TOKEN: "..." - - DB_NAME: "..." - DB_HOST: "..." - DB_PORT: "5432" - - COUCHDB_USER: "..." - COUCHDB_PASSWORD: "..." - COUCHDB_PORT: "5982" - - COUCH_CONNECTOR: "Server(\"http://127.0.0.1:5982\")" - CELERY_BACKEND: "redis://redis:6379/0" - DJANGO_SETTINGS_MODULE: "ahc.settings" - - EMAIL_BACKEND: "django.core.mail.backends.smtp.EmailBackend" - EMAIL_HOST: "sandbox.smtp.mailtrap.io" - EMAIL_PORT: "2525" - EMAIL_USE_TLS: "True" diff --git a/kubernetes/base/celery-beat/deployment.yaml b/kubernetes/base/celery-beat/deployment.yaml new file mode 100644 index 00000000..69917e87 --- /dev/null +++ b/kubernetes/base/celery-beat/deployment.yaml @@ -0,0 +1,52 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: celery-beat + labels: + app: celery-beat +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: celery-beat + template: + metadata: + labels: + app: celery-beat + spec: + containers: + - name: celery-beat + image: ghcr.io/cybernetic-ransomware/ahc-app:prod + command: ["celery", "-A", "celery_notifications.config:celery_obj", "beat", "-l", "info"] + envFrom: + - configMapRef: + name: ahc-app-config + - secretRef: + name: ahc-app-secrets + - secretRef: + name: couchdb-credentials + env: + - name: DB_NAME + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_DB + - name: DB_USER + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_USER + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_PASSWORD + resources: + requests: + memory: "64Mi" + cpu: "25m" + limits: + memory: "128Mi" + cpu: "100m" diff --git a/kubernetes/base/celery/deployment.yaml b/kubernetes/base/celery/deployment.yaml new file mode 100644 index 00000000..1dbd59c9 --- /dev/null +++ b/kubernetes/base/celery/deployment.yaml @@ -0,0 +1,96 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: celery-worker + labels: + app: celery-worker +spec: + replicas: 1 + selector: + matchLabels: + app: celery-worker + template: + metadata: + labels: + app: celery-worker + spec: + securityContext: + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + initContainers: + # Same rationale as in the web Deployment: the worker writes offline + # snapshots to the shared PVC as uid 1000, and the worker can start + # before the web pod has prepared the volume. + - name: prepare-storage + image: busybox:1.37 + securityContext: + runAsUser: 0 + command: + - sh + - -c + - | + set -eu + chown -R 1000:1000 /mnt/private + volumeMounts: + - name: app-private + mountPath: /mnt/private + resources: + requests: + memory: "16Mi" + cpu: "10m" + limits: + memory: "32Mi" + cpu: "50m" + containers: + - name: celery-worker + image: ghcr.io/cybernetic-ransomware/ahc-app:prod + # Explicit concurrency: the default equals the node CPU count, + # which forks more Django processes than the memory limit can hold. + command: ["celery", "-A", "celery_notifications.config:celery_obj", "worker", "-l", "info", "--concurrency=2"] + envFrom: + - configMapRef: + name: ahc-app-config + - secretRef: + name: ahc-app-secrets + - secretRef: + name: couchdb-credentials + env: + - name: DB_NAME + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_DB + - name: DB_USER + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_USER + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_PASSWORD + volumeMounts: + - name: app-private + mountPath: /app/private + livenessProbe: + exec: + command: + - sh + - -c + - celery -A celery_notifications.config:celery_obj inspect ping -d "celery@$HOSTNAME" --timeout 10 + initialDelaySeconds: 30 + periodSeconds: 120 + timeoutSeconds: 20 + failureThreshold: 3 + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "384Mi" + cpu: "300m" + volumes: + - name: app-private + persistentVolumeClaim: + claimName: app-private-pvc diff --git a/kubernetes/base/config/app-configmap.yaml b/kubernetes/base/config/app-configmap.yaml new file mode 100644 index 00000000..0baf4bff --- /dev/null +++ b/kubernetes/base/config/app-configmap.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: ahc-app-config + annotations: + argocd.argoproj.io/sync-wave: "-3" +data: + DEBUG: "False" + ALLOWED_HOSTS: "localhost,127.0.0.1" + CSRF_TRUSTED_ORIGINS: "" + DB_HOST: "postgres-service" + DB_PORT: "5432" + COUCHDB_HOST: "appendixes-db" + COUCHDB_PORT: "5982" + COUCHDB_DB_NAME: "appendixes" + CELERY_BROKER_URL: "redis://redis:6379/0" + CELERY_BACKEND: "redis://redis:6379/0" + EMAIL_BACKEND: "django.core.mail.backends.smtp.EmailBackend" + EMAIL_HOST: "sandbox.smtp.mailtrap.io" + EMAIL_PORT: "2525" + EMAIL_USE_TLS: "True" + DJANGO_SETTINGS_MODULE: "ahc.settings" + PRIVATE_STORAGE_ROOT: "/app/private" + FLOWER_PORT: "5555" + PYTHONUNBUFFERED: "1" diff --git a/kubernetes/base/couchdb/init-job.yaml b/kubernetes/base/couchdb/init-job.yaml new file mode 100644 index 00000000..83ad7cae --- /dev/null +++ b/kubernetes/base/couchdb/init-job.yaml @@ -0,0 +1,54 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: couchdb-init + annotations: + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/sync-wave: "-1" + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded +spec: + backoffLimit: 4 + activeDeadlineSeconds: 300 + template: + spec: + restartPolicy: Never + initContainers: + - name: wait-for-couchdb + image: busybox:1.37 + command: + - sh + - -c + - until nc -z appendixes-db 5982; do echo "waiting for couchdb"; sleep 2; done + resources: + requests: + memory: "16Mi" + cpu: "10m" + limits: + memory: "32Mi" + cpu: "50m" + containers: + - name: init + image: curlimages/curl:8.10.1 + envFrom: + - secretRef: + name: couchdb-credentials + command: + - sh + - -c + - | + set -u + for db in _users _replicator appendixes; do + code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT \ + "http://${COUCHDB_USER}:${COUCHDB_PASSWORD}@appendixes-db:5982/${db}") + case "$code" in + 201|202|412) echo "$db: $code (ok)" ;; + *) echo "$db: $code (fail)"; exit 1 ;; + esac + done + resources: + requests: + memory: "16Mi" + cpu: "10m" + limits: + memory: "64Mi" + cpu: "100m" diff --git a/kubernetes/base/couchdb/service.yaml b/kubernetes/base/couchdb/service.yaml new file mode 100644 index 00000000..5c6a7712 --- /dev/null +++ b/kubernetes/base/couchdb/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: appendixes-db + annotations: + argocd.argoproj.io/sync-wave: "-2" +spec: + type: ClusterIP + selector: + app: couchdb + ports: + # The app expects COUCHDB_PORT=5982 (legacy custom port); the official + # couchdb image listens on 5984, so the Service translates 5982 -> 5984. + - port: 5982 + targetPort: 5984 + protocol: TCP diff --git a/kubernetes/base/couchdb/statefulset.yaml b/kubernetes/base/couchdb/statefulset.yaml new file mode 100644 index 00000000..f761bea0 --- /dev/null +++ b/kubernetes/base/couchdb/statefulset.yaml @@ -0,0 +1,58 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: couchdb + labels: + app: couchdb + annotations: + argocd.argoproj.io/sync-wave: "-2" +spec: + serviceName: appendixes-db + replicas: 1 + selector: + matchLabels: + app: couchdb + template: + metadata: + labels: + app: couchdb + spec: + containers: + - name: couchdb + image: couchdb:3.5.2 + ports: + - containerPort: 5984 + protocol: TCP + envFrom: + - secretRef: + name: couchdb-credentials + volumeMounts: + - name: couchdb-data + mountPath: /opt/couchdb/data + readinessProbe: + httpGet: + path: /_up + port: 5984 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /_up + port: 5984 + initialDelaySeconds: 30 + periodSeconds: 15 + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + volumeClaimTemplates: + - metadata: + name: couchdb-data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi diff --git a/kubernetes/base/flower/deployment.yaml b/kubernetes/base/flower/deployment.yaml new file mode 100644 index 00000000..ad0c3933 --- /dev/null +++ b/kubernetes/base/flower/deployment.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: celery-flower + labels: + app: flower +spec: + replicas: 1 + selector: + matchLabels: + app: flower + template: + metadata: + labels: + app: flower + spec: + containers: + - name: celery-flower + image: mher/flower:2.0.1 + envFrom: + - configMapRef: + name: ahc-app-config + - secretRef: + name: flower-secrets + ports: + - containerPort: 5555 + protocol: TCP + readinessProbe: + tcpSocket: + port: 5555 + periodSeconds: 10 + livenessProbe: + tcpSocket: + port: 5555 + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + requests: + memory: "64Mi" + cpu: "25m" + limits: + memory: "128Mi" + cpu: "200m" diff --git a/kubernetes/queue/flower/service.yaml b/kubernetes/base/flower/service.yaml similarity index 69% rename from kubernetes/queue/flower/service.yaml rename to kubernetes/base/flower/service.yaml index 94489bf4..62e5e2a2 100644 --- a/kubernetes/queue/flower/service.yaml +++ b/kubernetes/base/flower/service.yaml @@ -3,10 +3,10 @@ kind: Service metadata: name: celery-flower-service spec: + type: ClusterIP selector: - app: celery_flower + app: flower ports: - - protocol: TCP - port: 5555 + - port: 5555 targetPort: 5555 - type: ClusterIP + protocol: TCP diff --git a/kubernetes/ingress.yaml b/kubernetes/base/ingress.yaml similarity index 74% rename from kubernetes/ingress.yaml rename to kubernetes/base/ingress.yaml index acb594b1..ed991621 100644 --- a/kubernetes/ingress.yaml +++ b/kubernetes/base/ingress.yaml @@ -3,8 +3,9 @@ kind: Ingress metadata: name: ingress-service annotations: - ingressClassName: nginx + argocd.argoproj.io/sync-wave: "1" spec: + # ingressClassName is set per overlay (nginx on minikube, traefik on k3s). rules: - http: paths: diff --git a/kubernetes/base/jobs/migrate-job.yaml b/kubernetes/base/jobs/migrate-job.yaml new file mode 100644 index 00000000..428414a6 --- /dev/null +++ b/kubernetes/base/jobs/migrate-job.yaml @@ -0,0 +1,65 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: ahc-migrate + annotations: + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/sync-wave: "-1" + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded +spec: + backoffLimit: 2 + activeDeadlineSeconds: 300 + template: + spec: + restartPolicy: Never + # Outside ArgoCD (plain kubectl apply -k) there is no wave ordering, + # so the job has to wait for the database itself. The headless + # postgres-service has no DNS records until the pod is ready. + initContainers: + - name: wait-for-postgres + image: busybox:1.37 + command: + - sh + - -c + - until nc -z postgres-service 5432; do echo "waiting for postgres"; sleep 2; done + resources: + requests: + memory: "16Mi" + cpu: "10m" + limits: + memory: "32Mi" + cpu: "50m" + containers: + - name: migrate + image: ghcr.io/cybernetic-ransomware/ahc-app:prod + command: ["python", "manage.py", "migrate", "--noinput"] + envFrom: + - configMapRef: + name: ahc-app-config + - secretRef: + name: ahc-app-secrets + - secretRef: + name: couchdb-credentials + env: + - name: DB_NAME + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_DB + - name: DB_USER + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_USER + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_PASSWORD + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "500m" diff --git a/kubernetes/base/kustomization.yaml b/kubernetes/base/kustomization.yaml new file mode 100644 index 00000000..a6071b15 --- /dev/null +++ b/kubernetes/base/kustomization.yaml @@ -0,0 +1,29 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: ahc + +resources: + - namespace.yaml + - config/app-configmap.yaml + - storage/app-media-pvc.yaml + - storage/app-private-pvc.yaml + - web/deployment.yaml + - web/service.yaml + - celery/deployment.yaml + - celery-beat/deployment.yaml + - flower/deployment.yaml + - flower/service.yaml + - redis/deployment.yaml + - redis/service.yaml + - postgres/statefulset.yaml + - postgres/service.yaml + - couchdb/statefulset.yaml + - couchdb/service.yaml + - couchdb/init-job.yaml + - jobs/migrate-job.yaml + - ingress.yaml + +images: + - name: ghcr.io/cybernetic-ransomware/ahc-app + newTag: prod diff --git a/kubernetes/base/namespace.yaml b/kubernetes/base/namespace.yaml new file mode 100644 index 00000000..2275e378 --- /dev/null +++ b/kubernetes/base/namespace.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: ahc + annotations: + # Deleting the namespace takes every PVC (including StatefulSet data) + # with it — require explicit confirmation in ArgoCD. + argocd.argoproj.io/sync-options: Prune=confirm,Delete=confirm diff --git a/kubernetes/base/postgres/service.yaml b/kubernetes/base/postgres/service.yaml new file mode 100644 index 00000000..f9a27b9f --- /dev/null +++ b/kubernetes/base/postgres/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: postgres-service + annotations: + argocd.argoproj.io/sync-wave: "-2" +spec: + clusterIP: None + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 + protocol: TCP diff --git a/kubernetes/base/postgres/statefulset.yaml b/kubernetes/base/postgres/statefulset.yaml new file mode 100644 index 00000000..4d43a763 --- /dev/null +++ b/kubernetes/base/postgres/statefulset.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + labels: + app: postgres + annotations: + argocd.argoproj.io/sync-wave: "-2" +spec: + serviceName: postgres-service + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:18-alpine + ports: + - containerPort: 5432 + protocol: TCP + envFrom: + - secretRef: + name: postgres-credentials + volumeMounts: + # postgres:18 images keep PGDATA in a versioned subdirectory + # (e.g. /var/lib/postgresql/18/docker), so the volume must cover + # /var/lib/postgresql, not the pre-18 /var/lib/postgresql/data. + - name: pgdata + mountPath: /var/lib/postgresql + readinessProbe: + exec: + command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""] + periodSeconds: 10 + livenessProbe: + exec: + command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""] + initialDelaySeconds: 30 + periodSeconds: 15 + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + volumeClaimTemplates: + - metadata: + name: pgdata + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi diff --git a/kubernetes/base/redis/deployment.yaml b/kubernetes/base/redis/deployment.yaml new file mode 100644 index 00000000..38dca1ec --- /dev/null +++ b/kubernetes/base/redis/deployment.yaml @@ -0,0 +1,40 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + labels: + app: redis + annotations: + argocd.argoproj.io/sync-wave: "-2" +spec: + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7-alpine + ports: + - containerPort: 6379 + protocol: TCP + readinessProbe: + exec: + command: ["redis-cli", "ping"] + periodSeconds: 10 + livenessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 15 + periodSeconds: 15 + resources: + requests: + memory: "32Mi" + cpu: "25m" + limits: + memory: "128Mi" + cpu: "200m" diff --git a/kubernetes/queue/redis/service.yaml b/kubernetes/base/redis/service.yaml similarity index 53% rename from kubernetes/queue/redis/service.yaml rename to kubernetes/base/redis/service.yaml index 9a2b4304..19f6552a 100644 --- a/kubernetes/queue/redis/service.yaml +++ b/kubernetes/base/redis/service.yaml @@ -1,12 +1,14 @@ apiVersion: v1 kind: Service metadata: - name: redis-service + name: redis + annotations: + argocd.argoproj.io/sync-wave: "-2" spec: + type: ClusterIP selector: app: redis ports: - - protocol: TCP - port: 6379 + - port: 6379 targetPort: 6379 - type: ClusterIP + protocol: TCP diff --git a/kubernetes/base/storage/app-media-pvc.yaml b/kubernetes/base/storage/app-media-pvc.yaml new file mode 100644 index 00000000..e9c5eed7 --- /dev/null +++ b/kubernetes/base/storage/app-media-pvc.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: app-media-pvc + annotations: + argocd.argoproj.io/sync-wave: "-3" + argocd.argoproj.io/sync-options: Prune=false,Delete=false +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi diff --git a/kubernetes/base/storage/app-private-pvc.yaml b/kubernetes/base/storage/app-private-pvc.yaml new file mode 100644 index 00000000..33f099cb --- /dev/null +++ b/kubernetes/base/storage/app-private-pvc.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: app-private-pvc + annotations: + argocd.argoproj.io/sync-wave: "-3" + argocd.argoproj.io/sync-options: Prune=false,Delete=false +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi diff --git a/kubernetes/base/web/deployment.yaml b/kubernetes/base/web/deployment.yaml new file mode 100644 index 00000000..49941c20 --- /dev/null +++ b/kubernetes/base/web/deployment.yaml @@ -0,0 +1,169 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ahc-app-backend + labels: + app: ahc-web +spec: + replicas: 1 + selector: + matchLabels: + app: ahc-web + template: + metadata: + labels: + app: ahc-web + spec: + securityContext: + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + initContainers: + # The app image runs as appuser (uid/gid 1000). hostPath-backed + # provisioners (minikube standard, k3s local-path) do not honor + # fsGroup, and kubelet creates subPath directories as root, so the + # volumes must be prepared explicitly before the app writes to them. + - name: prepare-storage + image: busybox:1.37 + securityContext: + runAsUser: 0 + command: + - sh + - -c + - | + set -eu + mkdir -p /mnt/media/animal_pic /mnt/media/attachments /mnt/media/profile_pics + chown -R 1000:1000 /mnt/media /mnt/private + volumeMounts: + - name: app-media + mountPath: /mnt/media + - name: app-private + mountPath: /mnt/private + resources: + requests: + memory: "16Mi" + cpu: "10m" + limits: + memory: "32Mi" + cpu: "50m" + - name: collectstatic + image: ghcr.io/cybernetic-ransomware/ahc-app:prod + command: ["python", "manage.py", "collectstatic", "--noinput"] + envFrom: + - configMapRef: + name: ahc-app-config + - secretRef: + name: ahc-app-secrets + - secretRef: + name: couchdb-credentials + env: + - name: DB_NAME + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_DB + - name: DB_USER + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_USER + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_PASSWORD + volumeMounts: + - name: static-collected + mountPath: /app/static_collected + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "500m" + containers: + - name: ahc-app-backend + image: ghcr.io/cybernetic-ransomware/ahc-app:prod + envFrom: + - configMapRef: + name: ahc-app-config + - secretRef: + name: ahc-app-secrets + - secretRef: + name: couchdb-credentials + env: + - name: DB_NAME + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_DB + - name: DB_USER + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_USER + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_PASSWORD + ports: + - containerPort: 8000 + protocol: TCP + volumeMounts: + - name: static-collected + mountPath: /app/static_collected + - name: app-media + mountPath: /app/static/media/animal_pic + subPath: animal_pic + - name: app-media + mountPath: /app/static/media/attachments + subPath: attachments + - name: app-media + mountPath: /app/static/media/profile_pics + subPath: profile_pics + - name: app-private + mountPath: /app/private + startupProbe: + httpGet: + path: /livez + port: 8000 + httpHeaders: + - name: Host + value: localhost + periodSeconds: 5 + failureThreshold: 30 + livenessProbe: + httpGet: + path: /livez + port: 8000 + httpHeaders: + - name: Host + value: localhost + periodSeconds: 20 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /readyz + port: 8000 + httpHeaders: + - name: Host + value: localhost + periodSeconds: 10 + timeoutSeconds: 5 + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + volumes: + - name: static-collected + emptyDir: {} + - name: app-media + persistentVolumeClaim: + claimName: app-media-pvc + - name: app-private + persistentVolumeClaim: + claimName: app-private-pvc diff --git a/kubernetes/backend/web/service.yaml b/kubernetes/base/web/service.yaml similarity index 69% rename from kubernetes/backend/web/service.yaml rename to kubernetes/base/web/service.yaml index 5f1551ce..32d5094a 100644 --- a/kubernetes/backend/web/service.yaml +++ b/kubernetes/base/web/service.yaml @@ -3,10 +3,10 @@ kind: Service metadata: name: ahc-app-backend-service spec: + type: ClusterIP selector: - app: ahc_app_backend + app: ahc-web ports: - - protocol: TCP - port: 8000 + - port: 8000 targetPort: 8000 - type: ClusterIP + protocol: TCP diff --git a/kubernetes/db/couchdb/configmap.yaml b/kubernetes/db/couchdb/configmap.yaml deleted file mode 100644 index f6173475..00000000 --- a/kubernetes/db/couchdb/configmap.yaml +++ /dev/null @@ -1,6 +0,0 @@ -#apiVersion: v1 -#kind: ConfigMap -#metadata: -# name: couchdb-config -#data: -# COUCHDB_PORT: "5982" diff --git a/kubernetes/db/couchdb/deployment.yaml b/kubernetes/db/couchdb/deployment.yaml deleted file mode 100644 index 7b094a9c..00000000 --- a/kubernetes/db/couchdb/deployment.yaml +++ /dev/null @@ -1,53 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: couch-db - labels: - app: couch-db -spec: - replicas: 1 - selector: - matchLabels: - app: couch-db-container - template: - metadata: - name: couch-db-container - labels: - app: couch-db-container - tier: backend - spec: - containers: - - name: couch-db-container - image: ahc_app-couch_db:latest - imagePullPolicy: Never - env: - - name: COUCHDB_USER - valueFrom: - secretKeyRef: - name: couchdb-secrets - key: COUCHDB_USER - - name: COUCHDB_PASSWORD - valueFrom: - secretKeyRef: - name: couchdb-secrets - key: COUCHDB_PASSWORD - - name: COUCHDB_PORT - valueFrom: - secretKeyRef: - name: couchdb-secrets - key: COUCHDB_PORT - volumeMounts: - - name: couchdbdata - mountPath: /opt/couchdb/data - resources: - limits: - memory: "256Mi" - cpu: "500m" - ports: - - containerPort: 5982 - protocol: TCP - volumes: - - name: couchdbdata - persistentVolumeClaim: - claimName: couch-db-volume-claim - restartPolicy: Always diff --git a/kubernetes/db/couchdb/kustomization.yaml b/kubernetes/db/couchdb/kustomization.yaml deleted file mode 100644 index bb6417d7..00000000 --- a/kubernetes/db/couchdb/kustomization.yaml +++ /dev/null @@ -1,7 +0,0 @@ -resources: - - storage-class.yaml - - pv.yaml - - pvc.yaml - - secret.yaml - - deployment.yaml - - service.yaml diff --git a/kubernetes/db/couchdb/pv.yaml b/kubernetes/db/couchdb/pv.yaml deleted file mode 100644 index 2e20965d..00000000 --- a/kubernetes/db/couchdb/pv.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: v1 -kind: PersistentVolume -metadata: - name: couch-db-volume - labels: - type: local - app: couch-db -spec: - storageClassName: local-couch-db - capacity: - storage: 2Gi -# volumeMode: Filesystem - accessModes: - - ReadWriteMany -# persistentVolumeReclaimPolicy: Retain - hostPath: - path: "/db" diff --git a/kubernetes/db/couchdb/pvc.yaml b/kubernetes/db/couchdb/pvc.yaml deleted file mode 100644 index bcd29321..00000000 --- a/kubernetes/db/couchdb/pvc.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: couch-db-volume-claim - labels: - app: couch-db -spec: - storageClassName: local-couch-db - accessModes: - - ReadWriteMany - resources: - requests: - storage: 2Gi - volumeName: couch-db-volume diff --git a/kubernetes/db/couchdb/secret.yaml.template b/kubernetes/db/couchdb/secret.yaml.template deleted file mode 100644 index d907d4bf..00000000 --- a/kubernetes/db/couchdb/secret.yaml.template +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: couchdb-secrets -type: Opaque -stringData: - COUCHDB_USER: "..." - COUCHDB_PASSWORD: "..." - COUCHDB_PORT: "5982" diff --git a/kubernetes/db/couchdb/service.yaml b/kubernetes/db/couchdb/service.yaml deleted file mode 100644 index 6fc69907..00000000 --- a/kubernetes/db/couchdb/service.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: appendixes-db -spec: - selector: - app: couch-db - ports: - - protocol: TCP - port: 5982 - targetPort: 5982 - type: ClusterIP diff --git a/kubernetes/db/couchdb/storage-class.yaml b/kubernetes/db/couchdb/storage-class.yaml deleted file mode 100644 index 15641f39..00000000 --- a/kubernetes/db/couchdb/storage-class.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: storage.k8s.io/v1 -kind: StorageClass -metadata: - name: local-couch-db -provisioner: kubernetes.io/no-provisioner -volumeBindingMode: WaitForFirstConsumer diff --git a/kubernetes/db/postgres/deployment.yaml b/kubernetes/db/postgres/deployment.yaml deleted file mode 100644 index 19dc6111..00000000 --- a/kubernetes/db/postgres/deployment.yaml +++ /dev/null @@ -1,39 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: postgres-db - labels: - app: postgres_db -spec: - replicas: 1 - selector: - matchLabels: - app: postgres_db - template: - metadata: - name: postgres-db - labels: - app: postgres_db - spec: - containers: - - name: postgres-db - image: postgres:18-alpine - imagePullPolicy: IfNotPresent - ports: - - containerPort: 5433 - protocol: TCP - envFrom: - - secretRef: - name: postgres-secrets -# volumeMounts: -# - mountPath: /var/lib/postgresql/data -# name: postgresdata - resources: - limits: - memory: "256Mi" - cpu: "500m" - volumes: - - name: postgresdata - persistentVolumeClaim: - claimName: postgres-volume-claim - restartPolicy: Always diff --git a/kubernetes/db/postgres/kustomization.yaml b/kubernetes/db/postgres/kustomization.yaml deleted file mode 100644 index bb6417d7..00000000 --- a/kubernetes/db/postgres/kustomization.yaml +++ /dev/null @@ -1,7 +0,0 @@ -resources: - - storage-class.yaml - - pv.yaml - - pvc.yaml - - secret.yaml - - deployment.yaml - - service.yaml diff --git a/kubernetes/db/postgres/pv.yaml b/kubernetes/db/postgres/pv.yaml deleted file mode 100644 index f9c3fbaa..00000000 --- a/kubernetes/db/postgres/pv.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: v1 -kind: PersistentVolume -metadata: - name: postgres-volume - labels: - type: local - app: postgres -spec: - storageClassName: local-postgres - capacity: - storage: 2Gi - volumeMode: Filesystem - accessModes: - - ReadWriteMany - persistentVolumeReclaimPolicy: Retain - hostPath: - path: db/postgres diff --git a/kubernetes/db/postgres/pvc.yaml b/kubernetes/db/postgres/pvc.yaml deleted file mode 100644 index 638704be..00000000 --- a/kubernetes/db/postgres/pvc.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: postgres-volume-claim - labels: - app: postgres -spec: - storageClassName: local-postgres - accessModes: - - ReadWriteMany - resources: - requests: - storage: 2Gi diff --git a/kubernetes/db/postgres/service.yaml b/kubernetes/db/postgres/service.yaml deleted file mode 100644 index 9a527114..00000000 --- a/kubernetes/db/postgres/service.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: postgres-service -spec: - selector: - app: ahc_app_backend - ports: - - protocol: TCP - port: 5433 - targetPort: 5432 - type: ClusterIP diff --git a/kubernetes/db/postgres/storage-class.yaml b/kubernetes/db/postgres/storage-class.yaml deleted file mode 100644 index 68dbf8d9..00000000 --- a/kubernetes/db/postgres/storage-class.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: storage.k8s.io/v1 -kind: StorageClass -metadata: - name: local-postgres -provisioner: kubernetes.io/no-provisioner -volumeBindingMode: WaitForFirstConsumer diff --git a/kubernetes/kustomization.yaml b/kubernetes/kustomization.yaml deleted file mode 100644 index 93aa2035..00000000 --- a/kubernetes/kustomization.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - ingress.yaml - - backend/web/ - - db/couchdb/ - - db/postgres/ - - queue/ diff --git a/kubernetes/overlays/home/backup/backup-pvc.yaml b/kubernetes/overlays/home/backup/backup-pvc.yaml new file mode 100644 index 00000000..3b2dcb3c --- /dev/null +++ b/kubernetes/overlays/home/backup/backup-pvc.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: backup-data + annotations: + argocd.argoproj.io/sync-wave: "-3" + argocd.argoproj.io/sync-options: Prune=false,Delete=false +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + # 14-day retention of nightly pg dumps + couchdb exports + full tars + # of two PVCs adds up fast; size accordingly rather than optimistically. + storage: 20Gi diff --git a/kubernetes/overlays/home/backup/couchdb-export-cronjob.yaml b/kubernetes/overlays/home/backup/couchdb-export-cronjob.yaml new file mode 100644 index 00000000..cae042a2 --- /dev/null +++ b/kubernetes/overlays/home/backup/couchdb-export-cronjob.yaml @@ -0,0 +1,50 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: couchdb-export +spec: + # Diagnostic document export, NOT a full backup: attachments and internal + # state are not fully covered by _all_docs. The real backup strategy is + # one-way replication to a second CouchDB instance or a PVC snapshot — + # see kubernetes/README.md. + schedule: "30 3 * * *" + timeZone: "Europe/Warsaw" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 600 + template: + spec: + restartPolicy: Never + containers: + - name: couchdb-export + image: curlimages/curl:8.10.1 + envFrom: + - secretRef: + name: couchdb-credentials + command: + - sh + - -c + - | + set -eu + f="/backup/couchdb-appendixes-$(date +%Y%m%d-%H%M%S).json.gz" + curl -fsS "http://${COUCHDB_USER}:${COUCHDB_PASSWORD}@appendixes-db:5982/appendixes/_all_docs?include_docs=true&attachments=true" \ + -H "Accept: application/json" | gzip > "$f" + echo "written $f" + find /backup -name 'couchdb-*.json.gz' -mtime +14 -delete + volumeMounts: + - name: backup + mountPath: /backup + resources: + requests: + memory: "32Mi" + cpu: "25m" + limits: + memory: "128Mi" + cpu: "200m" + volumes: + - name: backup + persistentVolumeClaim: + claimName: backup-data diff --git a/kubernetes/overlays/home/backup/files-backup-cronjob.yaml b/kubernetes/overlays/home/backup/files-backup-cronjob.yaml new file mode 100644 index 00000000..5445f5ed --- /dev/null +++ b/kubernetes/overlays/home/backup/files-backup-cronjob.yaml @@ -0,0 +1,56 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: files-backup +spec: + schedule: "0 4 * * *" + timeZone: "Europe/Warsaw" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 600 + template: + spec: + restartPolicy: Never + containers: + - name: files-backup + image: alpine:3.21 + command: + - sh + - -c + - | + set -eu + stamp=$(date +%Y%m%d-%H%M%S) + tar czf "/backup/media-$stamp.tar.gz" -C /data media + tar czf "/backup/private-$stamp.tar.gz" -C /data private + echo "written media-$stamp.tar.gz private-$stamp.tar.gz" + find /backup -name 'media-*.tar.gz' -mtime +14 -delete + find /backup -name 'private-*.tar.gz' -mtime +14 -delete + volumeMounts: + - name: app-media + mountPath: /data/media + readOnly: true + - name: app-private + mountPath: /data/private + readOnly: true + - name: backup + mountPath: /backup + resources: + requests: + memory: "32Mi" + cpu: "25m" + limits: + memory: "128Mi" + cpu: "200m" + volumes: + - name: app-media + persistentVolumeClaim: + claimName: app-media-pvc + - name: app-private + persistentVolumeClaim: + claimName: app-private-pvc + - name: backup + persistentVolumeClaim: + claimName: backup-data diff --git a/kubernetes/overlays/home/backup/pg-backup-cronjob.yaml b/kubernetes/overlays/home/backup/pg-backup-cronjob.yaml new file mode 100644 index 00000000..3aaf4d07 --- /dev/null +++ b/kubernetes/overlays/home/backup/pg-backup-cronjob.yaml @@ -0,0 +1,46 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: pg-backup +spec: + schedule: "0 3 * * *" + timeZone: "Europe/Warsaw" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 600 + template: + spec: + restartPolicy: Never + containers: + - name: pg-dump + image: postgres:18-alpine + envFrom: + - secretRef: + name: postgres-credentials + command: + - sh + - -c + - | + set -eu + export PGPASSWORD="$POSTGRES_PASSWORD" + f="/backup/pg-$(date +%Y%m%d-%H%M%S).dump" + pg_dump -h postgres-service -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc -f "$f" + echo "written $f" + find /backup -name 'pg-*.dump' -mtime +14 -delete + volumeMounts: + - name: backup + mountPath: /backup + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" + volumes: + - name: backup + persistentVolumeClaim: + claimName: backup-data diff --git a/kubernetes/overlays/home/celery-beat-patch.yaml b/kubernetes/overlays/home/celery-beat-patch.yaml new file mode 100644 index 00000000..f9c4f063 --- /dev/null +++ b/kubernetes/overlays/home/celery-beat-patch.yaml @@ -0,0 +1,9 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: celery-beat +spec: + # Kept at 0 until the cutover from the docker-compose/Watchtower stack: + # two live beat schedulers would send duplicate Discord notifications. + # Set to 1 when this cluster becomes the only production instance. + replicas: 0 diff --git a/kubernetes/overlays/home/configmap-patch.yaml b/kubernetes/overlays/home/configmap-patch.yaml new file mode 100644 index 00000000..d8bbcb33 --- /dev/null +++ b/kubernetes/overlays/home/configmap-patch.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: ahc-app-config +data: + # TODO: replace ahc.example.home with the real AHC_DOMAIN before the + # first home sync. localhost must stay in ALLOWED_HOSTS for kubelet probes. + ALLOWED_HOSTS: "localhost,127.0.0.1,ahc.example.home" + CSRF_TRUSTED_ORIGINS: "https://ahc.example.home" diff --git a/kubernetes/overlays/home/ingress-patch.yaml b/kubernetes/overlays/home/ingress-patch.yaml new file mode 100644 index 00000000..8db5d33a --- /dev/null +++ b/kubernetes/overlays/home/ingress-patch.yaml @@ -0,0 +1,27 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-service +spec: + # k3s ships Traefik as the default ingress controller. + ingressClassName: traefik + # TODO(bootstrap): CSRF_TRUSTED_ORIGINS uses https://, so TLS must be + # terminated here. Uncomment once a certificate exists (cert-manager or a + # manually created ahc-tls Secret); until then Traefik serves its default + # self-signed certificate. + # tls: + # - hosts: + # - ahc.example.home + # secretName: ahc-tls + rules: + # TODO: replace with the real AHC_DOMAIN before the first home sync. + - host: ahc.example.home + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: ahc-app-backend-service + port: + number: 8000 diff --git a/kubernetes/overlays/home/kustomization.yaml b/kubernetes/overlays/home/kustomization.yaml new file mode 100644 index 00000000..2a94630b --- /dev/null +++ b/kubernetes/overlays/home/kustomization.yaml @@ -0,0 +1,43 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# Production overlay for the home k3s cluster, synced by the ahc-home +# ArgoCD Application. Secrets are SealedSecrets encrypted with the home +# cluster controller key. The image tag below is managed by CI +# (kustomize edit set image after each image publish). +# +# The files under sealed/ must be generated with kubeseal against the +# home cluster before the first sync — see kubernetes/README.md. + +resources: + - ../../base + - sealed/app-sealedsecret.yaml + - sealed/postgres-sealedsecret.yaml + - sealed/couchdb-sealedsecret.yaml + - sealed/flower-sealedsecret.yaml + - backup/backup-pvc.yaml + - backup/pg-backup-cronjob.yaml + - backup/couchdb-export-cronjob.yaml + - backup/files-backup-cronjob.yaml + - smoke-test-job.yaml + +patches: + - path: ingress-patch.yaml + - path: configmap-patch.yaml + - path: celery-beat-patch.yaml + # kubeseal copies input Secret annotations into spec.template.metadata + # (the unsealed Secret), not onto the SealedSecret itself — without this + # patch SealedSecrets land in wave 0, after the databases that need them. + - target: + kind: SealedSecret + patch: |- + apiVersion: bitnami.com/v1alpha1 + kind: SealedSecret + metadata: + name: placeholder-overridden-by-target + annotations: + argocd.argoproj.io/sync-wave: "-3" + +images: + - name: ghcr.io/cybernetic-ransomware/ahc-app + newTag: prod diff --git a/kubernetes/overlays/home/smoke-test-job.yaml b/kubernetes/overlays/home/smoke-test-job.yaml new file mode 100644 index 00000000..9dfc32a4 --- /dev/null +++ b/kubernetes/overlays/home/smoke-test-job.yaml @@ -0,0 +1,30 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: ahc-smoke-test + annotations: + argocd.argoproj.io/hook: PostSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded +spec: + backoffLimit: 3 + activeDeadlineSeconds: 180 + template: + spec: + restartPolicy: Never + containers: + - name: smoke + image: curlimages/curl:8.10.1 + command: + - sh + - -c + - | + set -eu + curl -fsS -H "Host: localhost" http://ahc-app-backend-service:8000/livez + curl -fsS -H "Host: localhost" http://ahc-app-backend-service:8000/readyz + resources: + requests: + memory: "16Mi" + cpu: "10m" + limits: + memory: "64Mi" + cpu: "100m" diff --git a/kubernetes/overlays/minikube-argocd/ingress-patch.yaml b/kubernetes/overlays/minikube-argocd/ingress-patch.yaml new file mode 100644 index 00000000..d1fa3f63 --- /dev/null +++ b/kubernetes/overlays/minikube-argocd/ingress-patch.yaml @@ -0,0 +1,6 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-service +spec: + ingressClassName: nginx diff --git a/kubernetes/overlays/minikube-argocd/kustomization.yaml b/kubernetes/overlays/minikube-argocd/kustomization.yaml new file mode 100644 index 00000000..b8e3f0d9 --- /dev/null +++ b/kubernetes/overlays/minikube-argocd/kustomization.yaml @@ -0,0 +1,38 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# GitOps test overlay: SealedSecrets encrypted with the sealed-secrets +# controller key of the local minikube cluster. Used by the +# ahc-minikube-test ArgoCD Application to rehearse the full sync flow +# (waves, hooks, sealed secrets) before touching the home cluster. +# +# The files under sealed/ must be generated with kubeseal against the +# minikube cluster before this overlay can build — see kubernetes/README.md. + +resources: + - ../../base + - sealed/app-sealedsecret.yaml + - sealed/postgres-sealedsecret.yaml + - sealed/couchdb-sealedsecret.yaml + - sealed/flower-sealedsecret.yaml + +patches: + - path: ingress-patch.yaml + # kubeseal copies input Secret annotations into spec.template.metadata + # (the unsealed Secret), not onto the SealedSecret itself — without this + # patch SealedSecrets land in wave 0, after the databases that need them. + - target: + kind: SealedSecret + patch: |- + apiVersion: bitnami.com/v1alpha1 + kind: SealedSecret + metadata: + name: placeholder-overridden-by-target + annotations: + argocd.argoproj.io/sync-wave: "-3" + +images: + - name: ghcr.io/cybernetic-ransomware/ahc-app + # Pinned to the branch image for the ArgoCD rehearsal (see README, + # "Testing branch code through ArgoCD"). Revert to prod before merging. + newTag: sha-d515554252e936a41ee9a6d559732a284d788686 diff --git a/kubernetes/overlays/minikube-argocd/sealed/app-sealedsecret.yaml b/kubernetes/overlays/minikube-argocd/sealed/app-sealedsecret.yaml new file mode 100644 index 00000000..33305a3d --- /dev/null +++ b/kubernetes/overlays/minikube-argocd/sealed/app-sealedsecret.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: ahc-app-secrets + namespace: ahc +spec: + encryptedData: + DISCORD_TOKEN: AgCiLgqLvMlbfSoEUdSdlVOLqR8i95sEoBwhBFWXcfCGF+a3jE+SaWxwobxlA6KzRAX9aG6s7FReXMQr0SNdP1OnUEzTzIKgilJVjpqrDiKii56p+V0McLRLywH8NoANsga8picI38b9eNqTmd5j5ZpaUvt48c5ewt2dwT3gCbJr2sxAEh0FkNMrPFR//ER08UKu+FXMAU7CkuYGOKnGSS7Ai+m5NvUh4174HDP9KawAytcpohV1U5MdkePobzByTK3vSQ00ZTOtQmztlXsI0yxnLr6tjJDA4KyuBRcpFfqTwod0wt3kqoqFyU3rmU6CYw42BrNRDv+mnwkK8hnTEKgKAhiKXUrtKQcBfPbUWKk/++1dLYc9u+jsdugvTKZxR92ko9W90xC+CQDple/h0jPCZesixatZ1fFFRAcxpBzzKXSvBpVF0aEShz30VPg5hC0Odlqeb5Mr1NUiNY1zmdtP5lXE2/lcahx9pqd2tJMG5Ilcu3oAyMHcD1YY1TO73apk5V/D1LhCcxdVLgQB/EeWKA/moBT39grzRlf1eQrbIORyHy/xyKDrArkefPoBrhGNzQN4KmjpFRgGdc43kSE0yt4loqZtrkEAz7I0Y6+5UhQ9JL3WROUwJ9uhmTcmS0PzAndXn8XHHtsb3ZW4WOEo7pD15tCxBJNvuCNdOiQmE2yr0DBhhkbiwNZIbTjFP81vbmXxrYKZhdFR5P+8yQicqi0vOBBjPFmlg1bEeUjJTEoD9Hqp8vzDj49oHNUFcu9T6RCgc3YP8gcOndiDVPg5M0gRbDSr + EMAIL_HOST_PASSWORD: AgA7Kly0hCzVMbh+JGPTBICcfCUgYJYX+/qheWOvY5zVv/y44EKpoqauClr0WwfX99T+Z8WiiUVChBIa1SON4P00ehTPYhQ0YkuvNk60XgloMsOUgKpWIx73eE0AkMGrm0ftdgQ7J++acKr8vQRXhzg/YICrfIFJJ50FTHmhwPrsSKC54G0XNsuXzb+VRCuIuSn7SLW59u1RCff6I9+pXMqcM39H/zJhJP9qdxiEriUPgAksG3GZIPs2agozsgi8JJ1syDoAeszRn4MLdqhffdt+opDTrTEwyRpk1cNxDewZk0b63Nm4D/1gMCSzHmjJAaioVwXX1NKiFUTumyIXNspqS8qUFSthwHdVn6+5msM+fDriCmW+GEjnRoRfvs771h7DX3Gf04nnAQuBvdWDx0NrqxwjwqXP35C7RgTJHbvY5I5Dhr3BPhEZrsIMv2g+EDoLjyu+orXfAITGVIuzF6D15uxFmZqzS0UtnD3k0iEbHmBtqHKStBsukLITA4zCJ3Zrv1nGLk3lY4lwE0Y0gAdwYBa5Ck8cc1iLWDkEt5zXSWG1oKEP619FisniUBE3j5PySe3bpth+pay9qAqUcivcn+mUMXjcpPGUsiM7WtBd1d7vbwkO60+ZfvuqE6xWAi25069aBpBNX/hNv7e0lTc8j1iCUwxjnoyoMLbZnSx9oOqILQSEW+qbyfvevOFAYwQ0EOWZyty6m/Tdy6POOg== + EMAIL_HOST_USER: AgAJUcALDlKSHiEMlEXebHcyVg9jp6mgWZgZgM1iV0QolDCZyrbmtjePaiJrNjeHdmwdlJziyPYzOpqGEVMe3Vkyaho8QUxxTbZOYK68YbQ9IXYpo0KqrsMjoBjYi4cA4crkfM882XU0Qpve5GM3/cLJxpOas5uUx+iLdsxetQdrMT2UYFwDaLamitmMcJb/I0kz7EgUv/m8gGJ+SSLQzdGMZhLBw1Ip+8oN3+evxeFBjDF1cnHH89TjqcNZX+4fsGw0M4k3l6nINZ3h4ktjWm9GRbduU1nAFG17FGJMiahU+vi68A6+C5EKgpbxIMu/GYQimxIjW7McV6xctstb9YSgfOgtx2k1R4W16KVXECPKQ0uuV+rImV4XTDvsTw5aR22Pv5Z51CPmcWFIHcAD4jbbtzMQAcdzitHl4o7mJS69Np7+wyg1ur8eaN7dMxr55Ie+N0EqhecZgdHfyJWj1Sc3VqocK4Yspotn6DZ/ar/QzT3rXRlcVkcZl3TutWzBe/z2jSnx00brhkNpms1oyXThSIHaeKdEmsBcz7oEs/67mHlbvSUcHUUWQ6RIM4At7FMYiLQfCb95mgZNlp9A65jec2mFb3koEZpI17p2sumI73wTJkjc2valtXuJ9pdmMD7yY659o8ojBlOIsaOIrOOuQUqVJ+wCOf2/5ckj7+l+k4Bv3L4Pisq2dL99N1HxJjnnXQ8pkod2ErpdDbHswQ== + SECRET_KEY: AgCArhaFrkfy/ToWXfTDBF1Nqpob0438/SV6gFHwwLNQEiyv0EeL25NISceHQa/sTK9B1UNnalDybbk1ak+Bu12Fh9n26SMHplAsENkFlCgPXeBLnSfJdvIjgkp/1eUcvT2N9z/eYFgFJdBAwHMpdmC5vI3Vt1VjZ5CHoB8WWRrDl7zTBGWY6BugAczDn+PjnWwk2ygllaPO/b+5Z0XYl8+DSs/RoQxVaCzZ/tjwDdkcZ6Yi4jc1cNCml9/pRI9osAfyIwMGcmz97EwnNO8/rZkBmS/dXGyX4q/lro6fHw+Z5i3BP0nf/l7NI/XpfKUvAJyMj4DBeUd/YjnspXfxRUra+6WLdT0tQCXWSCQ8Wa2YUA9D0P8bkF1NcYKTLSEOf/ZVPyAnf4IM4FbERceXZDQZPExPZzAf+RUeHvUWxnx/nunkfQLIPS2vooCdw0brii79YlbaKSjJgWKUgtdXOa+MPFFSKbm362fQAZRT0i79Muq30W/ok9P08najhaSg0RTEXuJrT91e8TfAgqNiiqgpFQX+ml7+fv7Qcv4TXUsREpcWEkKlSiZr9sPJwU5SAwb62J7FAQXNGcQXuvdkQoauSULfGKtnRrGm/ONwi812ybTMvwqVGtxabsgI9aK027nt6kOAZKl1MYDBag7V3mfXOxa1jKGpx/ArXx+8cv723M/o47iK5qPQ4T7/AEaakAuI/eSvMYl3fEemv2J1Lg67yeJuoiRwyw5egEG52LuJk3b+qUAoFAOOjTjeWa/52Bs2tvE7Dki8qmvT971hFnO76V6K + template: + metadata: + annotations: + argocd.argoproj.io/sync-wave: "-3" + creationTimestamp: null + name: ahc-app-secrets + namespace: ahc diff --git a/kubernetes/overlays/minikube-argocd/sealed/couchdb-sealedsecret.yaml b/kubernetes/overlays/minikube-argocd/sealed/couchdb-sealedsecret.yaml new file mode 100644 index 00000000..a8662011 --- /dev/null +++ b/kubernetes/overlays/minikube-argocd/sealed/couchdb-sealedsecret.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: couchdb-credentials + namespace: ahc +spec: + encryptedData: + COUCHDB_PASSWORD: AgBLcq7HVmCbUPN7qqLRtgsBtsM+foW5oj6TGra+BWGKQG+AAcZCp8wMFgkqMov4LvYDJHXnG0JII7AAnZBy8PpHk9hmMSxYyI30TfjKsNdvPDRvQepakbWeJs1nW6oPFgoXDuu8y18fKLc+ETnwxzcjwcr26gqt7Vm5JxgiwBbCcAl+0HyUXNtx1hJkm1sLi/OM/ZczTIYC5fO3/9iPmfg9GgtKI2l5LZOLKj00ItGny2qpaufUwHYC8EW7w1VTtBe93CntZJteTWaXpMr535yihZgV3DufB+wlQUct74SoJvwhzgeQn89WoUQKjmlF5n1LgSFHpMr9IQxZl9ort7mnBVErcbnHAgvzC0/nwQLOqeTL6a8pULoggnlZt/hwCu1ZRFg3Rz0EiffffwEDDD56ERt9AnnIATTSz1Pe4VEw6I7d/ov4JhI+NiLvbuIW4zSGa7RCqS4+rcVw+KyfIMNlXAcjNWopHO72QAi9nK7jx68sRq6RzunYgSiKqJNsnjMkH3Qlqy42vMhd448FjDp6wZWZETTIWYiA1754IQtyhJuEwtZfm8VmRkfeSBEBKEAl5q/jQskceX9PJKRW9bdSamGD/RXPmG6+IuvHdwLZOe/ptAQel59j6n8y/joG6osVYN+nn50EJrfnqjTUAF+z36e2uCqp6msfj/Luc4KjGHhlbJjx86gzxkGYidaS130tJxLZTjs= + COUCHDB_USER: AgCU+DwkpeQBDdpjxWc4t2niyVicMQWIa1x8Dyf2OVs5X3Yi26rgfScasgEuUqvnQ4UusZOKa6/f7jSoTaxesBDPQgPLOwg/X2v6lmJq9Mnoi9wD3iy+cs3IjkGherhNn009gNo85Wh1nnmg/00e3MN6rKDEEGiHB+9rTSG4OIX5ycvfakhqNi5sz/FYgOJazU+g4+qPkgBsQAtKjI4njUB/dVxYgQF9IQV5nNi3Jruo9s7Rn2PfBgz9I78syKkrzSb7N7KbuG6NnPQAeQsohqkDx3ULA+QaoTYrnNqHB+N66IlCLYtRH/5w1QHAJbk80Ysp87ucP2OxbmP/07ZdrgDljl8Z2qBE5szLLPJzhEPkJqtonf8vJxi+6RFmC8mrCxktxDz4VBggYzcrQQtZal4f9zps4NnSOYWqlUa657TcSNmnKxFGOnyI7THVJRo9mNT0Ik/T3ETcs9bA2TqjjFRS/rMWGcmqQSM4QE1yfMJZSzuuw2KRv33WXYYOQEH2EHhr6kc5xM9p1oRTtcf1BhvhXm/90yeXJosL2wfARGxELEitO57o08JCjOr1YX/3pCepJCb0JeyGLK84BGT/WoebTQCnxlEur/CuaEVjC3HFYawlvjMXhdVrZtqyKUyJZE664qlDl/ezq9WKAMgw3Sk6mUowWeZdQR8vYAqa0MCxrlnzQZF1YL6I1PVVGcxRGzmTBGHWB7w= + template: + metadata: + annotations: + argocd.argoproj.io/sync-wave: "-3" + creationTimestamp: null + name: couchdb-credentials + namespace: ahc diff --git a/kubernetes/overlays/minikube-argocd/sealed/flower-sealedsecret.yaml b/kubernetes/overlays/minikube-argocd/sealed/flower-sealedsecret.yaml new file mode 100644 index 00000000..d55c7310 --- /dev/null +++ b/kubernetes/overlays/minikube-argocd/sealed/flower-sealedsecret.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: flower-secrets + namespace: ahc +spec: + encryptedData: + FLOWER_BASIC_AUTH: AgB51xcbMBCKjaOBUwY0f3K+M9yvXfc99Bdg73riTowf+GYCvI08CFIVq5DEwBDBXlvFVYhhMeLm74tYJlnURk0OAjP8O/KGXC2asNKWI3hKjEz+CKqEyG5p9/zQKcPuT/JehDwNQQGRa2zubphuKySEcoxlvac2GOVn9epkS8ECxxR94Yd8pkOxJ2xitnyRhY1fDZl9ncMIFC2oBE/9iYfVKsX71tCWByFiS2WwDK+W3VmwngFvdBgQVRNuQ7TZBKPKG5XSUiz+VVXrp15HKfD66S8CiwEsbVl/4BNeIVZB7fN0ILlvNcVspiOJpJt+BrCzCuQQdJQ88sotFMKJ+hmEAZme7FGIyerfqKK8hJd/455Md6UF/5aaVq9f1uGYhYDoSu1aFi1uQTtT62eCqEeBxLmKpLEUfrUfVCVYT4fPA2FnXuu5yaajiZ62ynd/y43NmQ4ecjW78qbym+2QuP3gAzCdjdp7t2ZoAmKkxUlwxNxxbur+c/9vpRM6JuVhVlemXR6Ndcw9ydD1qNorsjyjeeIjPzsPpF0SS4WeVqJlo3dIUZZRzJBI3/cgx05nCPXUAu7namwCIQAps2hNm7tIuuuLMAMTXJ9k0BQTVYSYxLJrtRFFYGK8zXMFPHpQw+lxcv+8PK753bkRaKsFu0AnNEPMU5broNX2uZIyVQcKiYkFjiO7TsG1h/0dTl84rmKCcMzawGKI1zOgKQ== + template: + metadata: + annotations: + argocd.argoproj.io/sync-wave: "-3" + creationTimestamp: null + name: flower-secrets + namespace: ahc diff --git a/kubernetes/overlays/minikube-argocd/sealed/postgres-sealedsecret.yaml b/kubernetes/overlays/minikube-argocd/sealed/postgres-sealedsecret.yaml new file mode 100644 index 00000000..4e397166 --- /dev/null +++ b/kubernetes/overlays/minikube-argocd/sealed/postgres-sealedsecret.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: postgres-credentials + namespace: ahc +spec: + encryptedData: + POSTGRES_DB: AgBGkgny6Z5TLw4be9Q7ZuacM9qT1fsHq37r4f1eQk/bKwe4afpxRat4hYop55GkvzHaileIbXIiLQCWd9jNpCIJkcIh1SLN06rW3RWLvGEohJyYjom3o1drfn6cfYGHF06PP1cisekOQeSdHcHOl2wpqPxK3H8fkqajMAEx27OAH8ASKtgQhi/68Tvrpa/D/xQtzvHZWbinxOcNg0JcR4tAefhXAY0OJ29XXzsdkOgT/dB2equUJuHw2xsRlLrBQ2mM6O7xVAIqgUU/3EUOoSwmGei5+hiDEdgBorbyYW7LhZWchy2ajPilxQ3veM5/2dCXI+BuQHEEloX81jTg/TLvStsHYW7ZzVN30bkqMEMZoIzmiqxdtxHMOco5Y5Gk6oUxwTr6Z9i23fgn6bhHHi66s/cA9Tc+Al6oEsfGJNVUv10cfKkd0oRDfHGvPgh6kvBTPDg4RPcIM7M6gc5fTkgqFhE78Gej0ej2U7ToLgL9iw8GP51g/bojzXVZF2VoS9q+jijuW+2D0+j7Rx5be5//OPdkrVJLSVdsiIAy+yN83v9jNAiCHXkPQo/TMadG1kBjTa5/Z6yZvbVr1qDXeqlW2wXbQyDKlAkryq/+ZDPz7VK34zA5HtDnjyXPipDK7wPNFDgcm0+nCq7GoE98DEBnfqMC7LpQGbNmbrQkkt13b3h6tPHjQkJ0uwUcK1IU6hQlJxflfOM= + POSTGRES_PASSWORD: AgCKszMjK5Fe3GHkQt+elcFsaU339N8+iQG5YmmaEjumoWo3rcmaa5H2cKxNJ/SVrzgqQcTS4WzE3lIwOWEQXWFP8N7DY6KnkjGMqWyAwNx0P1p/nT6xH7omJKSHp4U3CT9JV3n4BEga6wZFJnKOM/e0l8OT6p6Xg4YeLg08vZCV3NIgC4La2i8dug/rxqP9SfZuyFK5Hg/CDQtH1p0A10P1/4zSmqIHEs6ACDhXFMzMmGjJgTxOcpFt53NG6cCbEj4QnvIbmr0uiMtNVrD+/5PLxDCt9zrXzDwGCuMpThEZcEcpj8dhTvtPQkMJ7Uv/g6Jtd9Soh/Q1bznqjtN3X8eoEPW66ODM+uw0eyMum2NB3Oy93Ggw+sqI5/kqqI61tzs0W3U0uJKHTwWZBowQmjXatpPFRppwQe6jCrvBjdmMao0gyAGAYjbYdKGVSd9oL9fUqt+ME2s38PhEhZeyY6VCkdLrL1xvWPUrKKx363xaeQ097GzOwK78bfSe3vwkmKKbi9Q9AymVUTD2Z87BoLHdM3+D/UUD5WeP4AZgg0EEHlVJ1GUThWBIgMKFQz0NEM9sxeGlHivNpCgDdahDtg3LKFAmLwJP8gUutCh/08uiP82UN/ahLgqgkyGkNnHyaijlCRRU/R74dPuqtDZdavwwJjetMC5QBxBnbjz5MaV6MGGtmsJIKL3Ud6/1UaIFus+shszGmMjzJw== + POSTGRES_USER: AgCiRvh3pOVXEHl7Q+qgVS5eBLCicTxd70hpCuQ1YpTBKMApcR6i6IWsR0qvcNXmmZCBYNIufTfld/6vUjL69fC/Tfctx4Byh7Fo0/eZBGeZyTJyMDsrPQS2/jH526THB6fweSEGBY5/nSjfgzZ2swBwceE8ZygUz0ED8DaeXU73x0RQyZuBv0Tu3n8wDduQK7YQfDX4De8xKsC17r30zXOslwe4s5NCJ3h+KhD98fWiLi4In2yOnUwpbSh0nviLCtDx1m/DmdKbDJRsKnuZzMorHRzg5ediOESPzW9FDIEhfm/fdsz84cyB22KmTRm8XerPr//jauAHxAkzVEMNNEFwcvIsGoEqxIp0UnkravPh6qRvHdwBZGpxcQsNgc5BARl9RvrAXiN5iEb7uE5AsNeyVqvBbQJFsxvJw4uGwpqTm3bgxtrlbvmxiAn84QprVkLZ1S73zhzGw1SkypF1EfGk2Dj+FWwcOGJZjyrGj6DWO8K0vMhJdxb43MizqZrlbWRJXlUnLePi9X8D1uGzDh9gRdcEq+HmPrY5t89MohZVDyA9+9W4QugSRLfIRCDBSRhWVh9L2c7JpZoAWI7GkppJ0x9e29NTwVY1Ls091K1Sje7/KxUFbAj/jiTSQW28XSMT21ei24gtLIbsCdeRb7MgXFv+2uvhNBwTN3c3uX4QGFwy/ZNXrBywfhobruPuPdqDVpAupmQWaA== + template: + metadata: + annotations: + argocd.argoproj.io/sync-wave: "-3" + creationTimestamp: null + name: postgres-credentials + namespace: ahc diff --git a/kubernetes/overlays/minikube-local/ingress-patch.yaml b/kubernetes/overlays/minikube-local/ingress-patch.yaml new file mode 100644 index 00000000..d1fa3f63 --- /dev/null +++ b/kubernetes/overlays/minikube-local/ingress-patch.yaml @@ -0,0 +1,6 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-service +spec: + ingressClassName: nginx diff --git a/kubernetes/overlays/minikube-local/kustomization.yaml b/kubernetes/overlays/minikube-local/kustomization.yaml new file mode 100644 index 00000000..9872a3bb --- /dev/null +++ b/kubernetes/overlays/minikube-local/kustomization.yaml @@ -0,0 +1,21 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# Local development overlay: plain Secrets filled from the committed +# templates and applied with `kubectl apply -k`. The filled secret files are +# git-ignored, so this overlay MUST NOT be used as an ArgoCD Application +# source (ArgoCD checks out the repo and would not find them). + +resources: + - ../../base + - secrets/app-secret.yaml + - secrets/postgres-secret.yaml + - secrets/couchdb-secret.yaml + - secrets/flower-secret.yaml + +patches: + - path: ingress-patch.yaml + +images: + - name: ghcr.io/cybernetic-ransomware/ahc-app + newTag: prod diff --git a/kubernetes/overlays/minikube-local/secrets/app-secret.yaml.template b/kubernetes/overlays/minikube-local/secrets/app-secret.yaml.template new file mode 100644 index 00000000..500745e1 --- /dev/null +++ b/kubernetes/overlays/minikube-local/secrets/app-secret.yaml.template @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: ahc-app-secrets + namespace: ahc + annotations: + argocd.argoproj.io/sync-wave: "-3" +stringData: + SECRET_KEY: "..." + EMAIL_HOST_USER: "..." + EMAIL_HOST_PASSWORD: "..." + DISCORD_TOKEN: "..." diff --git a/kubernetes/overlays/minikube-local/secrets/couchdb-secret.yaml.template b/kubernetes/overlays/minikube-local/secrets/couchdb-secret.yaml.template new file mode 100644 index 00000000..c976fe76 --- /dev/null +++ b/kubernetes/overlays/minikube-local/secrets/couchdb-secret.yaml.template @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Secret +metadata: + name: couchdb-credentials + namespace: ahc + annotations: + argocd.argoproj.io/sync-wave: "-3" +stringData: + COUCHDB_USER: "..." + COUCHDB_PASSWORD: "..." diff --git a/kubernetes/overlays/minikube-local/secrets/flower-secret.yaml.template b/kubernetes/overlays/minikube-local/secrets/flower-secret.yaml.template new file mode 100644 index 00000000..db729660 --- /dev/null +++ b/kubernetes/overlays/minikube-local/secrets/flower-secret.yaml.template @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: flower-secrets + namespace: ahc + annotations: + argocd.argoproj.io/sync-wave: "-3" +stringData: + FLOWER_BASIC_AUTH: "user:password" diff --git a/kubernetes/db/postgres/secret.yaml.template b/kubernetes/overlays/minikube-local/secrets/postgres-secret.yaml.template similarity index 54% rename from kubernetes/db/postgres/secret.yaml.template rename to kubernetes/overlays/minikube-local/secrets/postgres-secret.yaml.template index 8cc46b89..b7e4753b 100644 --- a/kubernetes/db/postgres/secret.yaml.template +++ b/kubernetes/overlays/minikube-local/secrets/postgres-secret.yaml.template @@ -1,7 +1,10 @@ apiVersion: v1 kind: Secret metadata: - name: postgres-secrets + name: postgres-credentials + namespace: ahc + annotations: + argocd.argoproj.io/sync-wave: "-3" stringData: POSTGRES_DB: "..." POSTGRES_USER: "..." diff --git a/kubernetes/queue/celery-beat/deployment.yaml b/kubernetes/queue/celery-beat/deployment.yaml deleted file mode 100644 index 5952f2e9..00000000 --- a/kubernetes/queue/celery-beat/deployment.yaml +++ /dev/null @@ -1,42 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: celery-beat - labels: - app: celery-beat -spec: - replicas: 1 - selector: - matchLabels: - app: celery-beat - template: - metadata: - name: celery-beat - labels: - app: celery-beat - spec: - initContainers: - - name: init-myservice - image: busybox - command: [ 'sh', '-c', 'env' ] - envFrom: - - secretRef: - name: web-secrets - containers: - - name: celery-beat - image: ahc_app-queue:latest - imagePullPolicy: Never - command: [ "celery", "-A", "celery_notifications.config:celery_obj", "beat", "-l", "info" ] - env: - - name: PYTHONUNBUFFERED - value: "1" - - name: PYTHONPATH - value: "/app/src" - envFrom: - - secretRef: - name: web-secrets - resources: - limits: - memory: "128Mi" - cpu: "250m" - restartPolicy: Always diff --git a/kubernetes/queue/celery-beat/kustomization.yaml b/kubernetes/queue/celery-beat/kustomization.yaml deleted file mode 100644 index 9519a26d..00000000 --- a/kubernetes/queue/celery-beat/kustomization.yaml +++ /dev/null @@ -1,2 +0,0 @@ -resources: - - deployment.yaml diff --git a/kubernetes/queue/celery/configmap.yaml b/kubernetes/queue/celery/configmap.yaml deleted file mode 100644 index 4b280519..00000000 --- a/kubernetes/queue/celery/configmap.yaml +++ /dev/null @@ -1,6 +0,0 @@ -#apiVersion: v1 -#kind: ConfigMap -#metadata: -# name: celery-config -#data: -# DJANGO_SETTINGS_MODULE: ahc.settings diff --git a/kubernetes/queue/celery/deployment.yaml b/kubernetes/queue/celery/deployment.yaml deleted file mode 100644 index e89a7cc5..00000000 --- a/kubernetes/queue/celery/deployment.yaml +++ /dev/null @@ -1,45 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: queue - labels: - app: queue -spec: - replicas: 1 - selector: - matchLabels: - app: queue - template: - metadata: - name: queue - labels: - app: queue - spec: - initContainers: - - name: init-myservice - image: busybox - command: [ 'sh', '-c', 'env' ] - envFrom: - - secretRef: - name: web-secrets - containers: - - name: queue - image: ahc_app-queue:latest - imagePullPolicy: Never - command: [ "celery", "-A", "celery_notifications.config:celery_obj", "worker", "-l", "info"] - env: - - name: PYTHONUNBUFFERED - value: "1" - - name: PYTHONPATH - value: "/app/src" - envFrom: - - secretRef: - name: web-secrets - ports: - - containerPort: 5000 - protocol: TCP - resources: - limits: - memory: "256Mi" - cpu: "500m" - restartPolicy: Always diff --git a/kubernetes/queue/celery/kustomization.yaml b/kubernetes/queue/celery/kustomization.yaml deleted file mode 100644 index 6d1374a1..00000000 --- a/kubernetes/queue/celery/kustomization.yaml +++ /dev/null @@ -1,3 +0,0 @@ -resources: - - deployment.yaml - - service.yaml diff --git a/kubernetes/queue/celery/secret.yaml.template b/kubernetes/queue/celery/secret.yaml.template deleted file mode 100644 index 6e95f604..00000000 --- a/kubernetes/queue/celery/secret.yaml.template +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: queue-secrets - -stringData: - DB_USER: "..." - DB_PASSWORD: "..." - - EMAIL_HOST_USER: "..." - EMAIL_HOST_PASSWORD: "..." - - DISCORD_TOKEN: "..." - - DB_NAME: "..." - DB_HOST: "..." - DB_PORT: "5432" - - COUCHDB_USER: "..." - COUCHDB_PASSWORD: "..." - COUCHDB_PORT: "5982" - - COUCH_CONNECTOR: "Server(\"http://127.0.0.1:5982\")" - CELERY_BACKEND: "redis://redis:6379/0" - DJANGO_SETTINGS_MODULE: "ahc.settings" - - EMAIL_BACKEND: "django.core.mail.backends.smtp.EmailBackend" - EMAIL_HOST: "sandbox.smtp.mailtrap.io" - EMAIL_PORT: "2525" - EMAIL_USE_TLS: "True" diff --git a/kubernetes/queue/celery/service.yaml b/kubernetes/queue/celery/service.yaml deleted file mode 100644 index b95fcf34..00000000 --- a/kubernetes/queue/celery/service.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: queue-service -spec: - selector: - app: queue - ports: - - protocol: TCP - port: 5000 - targetPort: 5000 - type: ClusterIP diff --git a/kubernetes/queue/flower/deployment.yaml b/kubernetes/queue/flower/deployment.yaml deleted file mode 100644 index b02d57f3..00000000 --- a/kubernetes/queue/flower/deployment.yaml +++ /dev/null @@ -1,32 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: celery-flower - labels: - app: celery_flower -spec: - replicas: 1 - selector: - matchLabels: - app: celery_flower - template: - metadata: - name: celery-flower - labels: - app: celery_flower - spec: - containers: - - name: celery-flower - image: mher/flower:2.0.1 - imagePullPolicy: Always - ports: - - containerPort: 5555 - protocol: TCP - envFrom: - - secretRef: - name: queue-secrets - resources: - limits: - memory: "256Mi" - cpu: "500m" - restartPolicy: Always diff --git a/kubernetes/queue/flower/kustomization.yaml b/kubernetes/queue/flower/kustomization.yaml deleted file mode 100644 index 6d1374a1..00000000 --- a/kubernetes/queue/flower/kustomization.yaml +++ /dev/null @@ -1,3 +0,0 @@ -resources: - - deployment.yaml - - service.yaml diff --git a/kubernetes/queue/kustomization.yaml b/kubernetes/queue/kustomization.yaml deleted file mode 100644 index d578b4af..00000000 --- a/kubernetes/queue/kustomization.yaml +++ /dev/null @@ -1,6 +0,0 @@ -resources: - - secret.yaml - - celery/ - - celery-beat/ - - flower/ - - redis/ diff --git a/kubernetes/queue/redis/deployment.yaml b/kubernetes/queue/redis/deployment.yaml deleted file mode 100644 index 1bf969fe..00000000 --- a/kubernetes/queue/redis/deployment.yaml +++ /dev/null @@ -1,32 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: redis - labels: - app: redis -spec: - replicas: 1 - selector: - matchLabels: - app: redis - template: - metadata: - name: redis - labels: - app: redis - spec: - containers: - - name: redis - image: redis:7-alpine - imagePullPolicy: Always - ports: - - containerPort: 6379 - protocol: TCP - envFrom: - - secretRef: - name: queue-secrets - resources: - limits: - memory: "256Mi" - cpu: "500m" - restartPolicy: Always diff --git a/kubernetes/queue/redis/kustomization.yaml b/kubernetes/queue/redis/kustomization.yaml deleted file mode 100644 index 6d1374a1..00000000 --- a/kubernetes/queue/redis/kustomization.yaml +++ /dev/null @@ -1,3 +0,0 @@ -resources: - - deployment.yaml - - service.yaml diff --git a/kubernetes/queue/secret.yaml.template b/kubernetes/queue/secret.yaml.template deleted file mode 100644 index 3afb2e2e..00000000 --- a/kubernetes/queue/secret.yaml.template +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: queue-secrets -stringData: - FLOWER_BASIC_AUTH: "...:..." - CELERY_BROKER_URL: "redis://redis:6379/0" - FLOWER_PORT: "5555" diff --git a/src/ahc/apps/homepage/tests/test_health.py b/src/ahc/apps/homepage/tests/test_health.py new file mode 100644 index 00000000..276e4c5d --- /dev/null +++ b/src/ahc/apps/homepage/tests/test_health.py @@ -0,0 +1,31 @@ +from unittest.mock import patch + +import pytest +from django.db import DatabaseError + + +@pytest.mark.unit +def test_livez_returns_ok_without_touching_the_database(client): + response = client.get("/livez") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +@pytest.mark.unit +@pytest.mark.django_db +def test_readyz_returns_ok_when_database_is_reachable(client): + response = client.get("/readyz") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +@pytest.mark.unit +def test_readyz_returns_503_when_database_is_down(client): + with patch("ahc.views.connection") as mock_connection: + mock_connection.cursor.side_effect = DatabaseError("connection refused") + response = client.get("/readyz") + + assert response.status_code == 503 + assert response.json() == {"status": "unavailable"} diff --git a/src/ahc/urls.py b/src/ahc/urls.py index b95d4e1f..cb782b2e 100644 --- a/src/ahc/urls.py +++ b/src/ahc/urls.py @@ -21,8 +21,12 @@ from django.views.generic.base import RedirectView from django.views.static import serve +from ahc.views import livez, readyz + urlpatterns = [ path("admin/", admin.site.urls), + path("livez", livez, name="livez"), + path("readyz", readyz, name="readyz"), path("", include("ahc.apps.homepage.urls")), path("user/", include("ahc.apps.users.urls")), path("pet/", include("ahc.apps.animals.urls")), diff --git a/src/ahc/views.py b/src/ahc/views.py new file mode 100644 index 00000000..1878041c --- /dev/null +++ b/src/ahc/views.py @@ -0,0 +1,20 @@ +"""Project-level operational views (health probes).""" + +from django.db import DatabaseError, connection +from django.http import HttpRequest, JsonResponse + + +def livez(request: HttpRequest) -> JsonResponse: + """Liveness probe: the process is up. No external dependencies.""" + return JsonResponse({"status": "ok"}) + + +def readyz(request: HttpRequest) -> JsonResponse: + """Readiness probe: the app can serve traffic (database reachable).""" + try: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + cursor.fetchone() + except DatabaseError: + return JsonResponse({"status": "unavailable"}, status=503) + return JsonResponse({"status": "ok"}) diff --git a/workflows/deploy-smoke-workflowtemplate.yaml b/workflows/deploy-smoke-workflowtemplate.yaml new file mode 100644 index 00000000..6780dec2 --- /dev/null +++ b/workflows/deploy-smoke-workflowtemplate.yaml @@ -0,0 +1,52 @@ +apiVersion: argoproj.io/v1alpha1 +kind: WorkflowTemplate +metadata: + name: ahc-deploy-smoke + namespace: argo +spec: + entrypoint: main + serviceAccountName: ahc-deployer + arguments: + parameters: + - name: image-tag + value: "prod" + templates: + - name: main + steps: + - - name: verify-image + template: verify-image + - - name: wait-rollout + template: rollout-status + - - name: smoke + template: smoke-test + + # Guard against blessing a stale deployment: after a CI-triggered submit + # ArgoCD may not have synced the bump commit yet, and rollout status of + # the previous revision would succeed. Wait until the Deployment actually + # references the expected image tag first. + - name: verify-image + container: + image: bitnami/kubectl:1.31 + command: [sh, -c] + args: + - >- + kubectl -n ahc wait deployment/ahc-app-backend + --for=jsonpath='{.spec.template.spec.containers[0].image}'=ghcr.io/cybernetic-ransomware/ahc-app:{{workflow.parameters.image-tag}} + --timeout=600s + + - name: rollout-status + container: + image: bitnami/kubectl:1.31 + command: [sh, -c] + args: + - kubectl -n ahc rollout status deployment/ahc-app-backend --timeout=300s + + - name: smoke-test + container: + image: curlimages/curl:8.10.1 + command: [sh, -c] + args: + - | + set -eu + curl -fsS -H "Host: localhost" http://ahc-app-backend-service.ahc.svc:8000/livez + curl -fsS -H "Host: localhost" http://ahc-app-backend-service.ahc.svc:8000/readyz diff --git a/workflows/rbac.yaml b/workflows/rbac.yaml new file mode 100644 index 00000000..0e5ffe28 --- /dev/null +++ b/workflows/rbac.yaml @@ -0,0 +1,59 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: ahc-deployer + namespace: argo +--- +# Minimum executor permissions required by Argo Workflows >= 3.4: every +# workflow pod reports step results as workflowtaskresults in the workflow +# namespace, regardless of what the steps themselves do. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: ahc-workflow-executor + namespace: argo +rules: + - apiGroups: ["argoproj.io"] + resources: ["workflowtaskresults"] + verbs: ["create", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: ahc-workflow-executor + namespace: argo +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ahc-workflow-executor +subjects: + - kind: ServiceAccount + name: ahc-deployer + namespace: argo +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: ahc-deployer + namespace: ahc +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch"] + - apiGroups: ["apps"] + resources: ["replicasets"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: ahc-deployer + namespace: ahc +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ahc-deployer +subjects: + - kind: ServiceAccount + name: ahc-deployer + namespace: argo