diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..49913ea --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,198 @@ +name: Deploy nextcloud + +# Push to main → path-filtered apply against the in-cluster k3s API via the +# runner pod's ServiceAccount (`arc-itguys-ro-nextcloud-gha-rs-no-permission`, +# bound to Role `nextcloud-deployer` in ns nextcloud and Role `cf-token-writer` +# in ns cert-manager — see manifests/bootstrap/11-ci-deployer-rbac.yaml). +# +# `secrets` always runs (cheap, idempotent). `manifests` and `helm` jobs run +# only when their respective paths changed (or workflow_dispatch reconcile_all). + +on: + push: + branches: [main] + paths: + - 'helm/**' + - 'manifests/**' + - '.github/workflows/deploy.yml' + workflow_dispatch: + inputs: + reconcile_all: + description: "Apply everything (helm + manifests + secrets) regardless of paths" + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: deploy-nextcloud + cancel-in-progress: false + +env: + KUBE_NS: nextcloud + HELM_RELEASE: nextcloud + HELM_CHART_VERSION: "9.1.0" + HELM_REPO_URL: "https://nextcloud.github.io/helm/" + +jobs: + changes: + runs-on: arc-itguys-ro-nextcloud + outputs: + helm: ${{ steps.f.outputs.helm }} + manifests: ${{ steps.f.outputs.manifests }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 2 + - id: f + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + with: + filters: | + helm: + - 'helm/**' + - '.github/workflows/deploy.yml' + manifests: + - 'manifests/*.yaml' + - '!manifests/60-nginx-tls-proxy.yaml' + + secrets: + runs-on: arc-itguys-ro-nextcloud + steps: + - uses: azure/setup-kubectl@829323503d1be3d00ca8346e5391ca0b07a9ab0d # v5.1.0 + - name: Verify in-cluster auth (SelfSubjectAccessReview, no RBAC needed) + run: | + set -euo pipefail + kubectl auth can-i update secret/cloudflare-api-token -n cert-manager + kubectl auth can-i create secret -n nextcloud + kubectl auth can-i create deployment.apps -n nextcloud + - name: Render and apply Secret/cloudflare-api-token (cert-manager) + env: + CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + run: | + set -euo pipefail + : "${CF_API_TOKEN:?missing GH secret CF_API_TOKEN}" + # Patch in place (Role permits update only on this name). + kubectl -n cert-manager patch secret cloudflare-api-token \ + --type=merge \ + -p "{\"stringData\":{\"api-token\":\"${CF_API_TOKEN}\"}}" + - name: Render and apply nextcloud-admin + env: + NEXTCLOUD_ADMIN_USERNAME: ${{ secrets.NEXTCLOUD_ADMIN_USERNAME }} + NEXTCLOUD_ADMIN_PASSWORD: ${{ secrets.NEXTCLOUD_ADMIN_PASSWORD }} + run: | + set -euo pipefail + : "${NEXTCLOUD_ADMIN_USERNAME:?missing}" + : "${NEXTCLOUD_ADMIN_PASSWORD:?missing}" + kubectl -n "${KUBE_NS}" apply --server-side -f - < "$tmp" + kubectl create secret generic backup-ssh \ + -n "${KUBE_NS}" \ + --from-file=id_ed25519="$tmp" \ + --dry-run=client -o yaml \ + | kubectl apply --server-side -f - + + manifests: + needs: [changes, secrets] + if: ${{ needs.changes.outputs.manifests == 'true' || inputs.reconcile_all }} + runs-on: arc-itguys-ro-nextcloud + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: azure/setup-kubectl@829323503d1be3d00ca8346e5391ca0b07a9ab0d # v5.1.0 + - name: Apply manifests (CI-scoped only; excludes bootstrap/ and shared nginx proxy) + run: | + set -euo pipefail + for f in \ + manifests/20-certificate.yaml \ + manifests/30-mariadb.yaml \ + manifests/40-valkey.yaml \ + manifests/50-nextcloud-pvcs.yaml \ + manifests/70-backup-cronjob.yaml; do + echo "::group::apply $f" + kubectl apply -f "$f" + echo "::endgroup::" + done + - name: Wait for non-chart Deployment rollouts + run: | + set -euo pipefail + kubectl -n "${KUBE_NS}" rollout status deploy/nextcloud-mariadb --timeout=3m + kubectl -n "${KUBE_NS}" rollout status deploy/valkey --timeout=2m + + helm: + needs: [changes, secrets] + if: ${{ needs.changes.outputs.helm == 'true' || inputs.reconcile_all }} + runs-on: arc-itguys-ro-nextcloud + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 + - uses: azure/setup-kubectl@829323503d1be3d00ca8346e5391ca0b07a9ab0d # v5.1.0 + - name: helm upgrade --install nextcloud + run: | + set -euo pipefail + helm repo add nextcloud "${HELM_REPO_URL}" + helm repo update nextcloud + helm upgrade --install "${HELM_RELEASE}" nextcloud/nextcloud \ + --namespace "${KUBE_NS}" \ + --version "${HELM_CHART_VERSION}" \ + -f helm/nextcloud-values.yaml \ + --atomic --timeout 5m + - name: Wait for nextcloud rollout + run: kubectl -n "${KUBE_NS}" rollout status deploy/nextcloud --timeout=5m diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..a19d5fd --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,72 @@ +name: PR checks + +# Validates manifests + helm chart against the live API server WITHOUT applying. +# No GH Secrets are consumed; no Secret manifests are rendered. + +on: + pull_request: + paths: + - 'helm/**' + - 'manifests/**' + - '.github/workflows/deploy.yml' + - '.github/workflows/pr-checks.yml' + +permissions: + contents: read + +concurrency: + group: pr-checks-${{ github.ref }} + cancel-in-progress: true + +env: + KUBE_NS: nextcloud + HELM_CHART_VERSION: "9.1.0" + HELM_REPO_URL: "https://nextcloud.github.io/helm/" + +jobs: + validate: + runs-on: arc-itguys-ro-nextcloud + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: azure/setup-kubectl@829323503d1be3d00ca8346e5391ca0b07a9ab0d # v5.1.0 + - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 + + - name: helm template + server-side dry-run + run: | + set -euo pipefail + helm repo add nextcloud "${HELM_REPO_URL}" + helm repo update nextcloud + helm template nextcloud nextcloud/nextcloud \ + --version "${HELM_CHART_VERSION}" \ + --namespace "${KUBE_NS}" \ + -f helm/nextcloud-values.yaml \ + | kubectl apply -n "${KUBE_NS}" --dry-run=server -f - + + - name: kubectl --dry-run=server -f manifests (CI-scoped subset) + run: | + set -euo pipefail + for f in \ + manifests/20-certificate.yaml \ + manifests/30-mariadb.yaml \ + manifests/40-valkey.yaml \ + manifests/50-nextcloud-pvcs.yaml \ + manifests/70-backup-cronjob.yaml; do + echo "::group::dry-run $f" + kubectl apply --dry-run=server -f "$f" + echo "::endgroup::" + done + + - name: kubectl diff (advisory; does not fail the check) + continue-on-error: true + run: | + set -euo pipefail + for f in \ + manifests/20-certificate.yaml \ + manifests/30-mariadb.yaml \ + manifests/40-valkey.yaml \ + manifests/50-nextcloud-pvcs.yaml \ + manifests/70-backup-cronjob.yaml; do + echo "::group::diff $f" + kubectl diff -f "$f" || true + echo "::endgroup::" + done diff --git a/CLAUDE.md b/CLAUDE.md index 00130a0..a1f8cbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,28 +1,28 @@ # CLAUDE.md -Deployment artifacts for Nextcloud on existing 3-node k3s-over-Cloudflare-WARP-Mesh homelab. Not app code — no build/lint/test. Live at `https://nextcloud.itguys.ro` (Mesh-only, :443 via proxy hostPort). +Nextcloud deploy artifacts on 3-node k3s-over-Cloudflare-WARP-Mesh. Not app code. Live `https://nextcloud.itguys.ro` (Mesh-only, :443 via proxy hostPort). -## Operating model: versioned-imperative (no GitOps) +## Operating model: push-to-main CI -- No GitOps controller; nothing here auto-applied. Changes reach cluster only via manual `helm upgrade -f ...` / `kubectl apply -f ...` by operator. -- Adding artifact → also update apply-order docs in `README.md`. Repo = record; human = deployer. -- No cluster CI — validate before propose-apply: `helm template -f helm/.yaml` + `helm lint`; `kubectl apply --dry-run=server -f manifests/.yaml`. - -## Source-of-truth (read before cluster-affecting work) - -- `docs/2026-05-17-nextcloud-k3s-design.md` — approved design. Decisions settled unless user reopens. -- `~/cloudflare-mesh-k3s-state.md` (outside repo) — cluster runbook; SoT for node names, Mesh IPs, existing components (degoog, headlamp). This repo intentionally doesn't duplicate. +- Push main → ARC runner `arc-itguys-ro-nextcloud` (asus-pinned, in-cluster) runs `.github/workflows/deploy.yml`. PR → `pr-checks.yml` dry-runs (no apply, no secrets). +- Runner SA bound to Role `nextcloud-deployer` (ns `nextcloud`, broad CRUD) + Role `cf-token-writer` (ns `cert-manager`, narrow update of Secret `cloudflare-api-token` only). RBAC at `manifests/bootstrap/11-ci-deployer-rbac.yaml`. +- Path filter SKIPS `manifests/60-nginx-tls-proxy.yaml` — shared :443 also serves headlamp/degoog/searxng/degoog-mcp w/ extra vhosts + `/tls-warp` cert mount not in repo. Edit live out-of-band; repo file = historical baseline (header warns DO NOT apply). +- `manifests/bootstrap/` + ARC scale set helm release = one-time manual (CI cannot grant itself rights). Re-apply on fresh cluster. +- `workflow_dispatch` with `reconcile_all=true` forces full reconcile. +- Design: `docs/superpowers/specs/2026-05-25-nextcloud-ci-deployment-design.md`. Original: `docs/2026-05-17-nextcloud-k3s-design.md`. +- Cluster runbook (outside repo): `~/cloudflare-mesh-k3s-state.md` — SoT for node names, Mesh IPs, components. ## Architecture invariants (don't violate without revisiting design) -- **Single-node pin:** whole stack in ns `nextcloud` w/ `nodeSelector: kubernetes.io/hostname=asus-laptop` on every pod. Storage = `local-path` (node-local RWO) on asus only. Nothing may schedule to `acer-laptop` or `wsl`. No storage HA — accepted trade-off. -- **Mesh-only, no public:** asus-pinned nginx TLS proxy binds asus host `:443` via `hostPort` (single replica; Service ClusterIP). DNS `nextcloud.itguys.ro` → A `100.96.0.2`, DNS-only / grey-cloud. Cluster cloudflared tunnel deliberately NOT used. No ingress controller (traefik disabled) — this proxy = single shared :443 TLS entrypoint; future apps = added nginx SNI server blocks + own cert. -- **Components:** official `nextcloud/nextcloud` Helm chart (Apache image, plain HTTP on Service :8080; chart's nginx sidecar disabled — TLS terminated by separate front nginx, plan Deviation #1). Dedicated MariaDB (Postgres explicitly rejected). Dedicated Valkey for `memcache.locking`/`memcache.distributed` — fresh instance, do NOT reuse degoog's Valkey. TLS via cert-manager (ns `cert-manager`) w/ Cloudflare DNS-01 ClusterIssuer, auto-renewed (proxy self-reloads on rotation). -- **Backups ≠ replication:** nightly asus-pinned CronJob does `mariadb-dump --single-transaction` (no `occ`/maintenance-mode — plan Deviation #2) + `config.php` copy to local PVC, then `rsync` data + dump to `acer-laptop` over Mesh SSH. Acer copy = real recovery path; local PVC only covers accidental deletion. No live storage replication by design. +- **Single-node pin:** whole stack in ns `nextcloud` w/ `nodeSelector: kubernetes.io/hostname=asus-laptop` on every pod. Storage = `local-path` (node-local RWO) on asus only. Nothing schedules to `acer-laptop` or `wsl`. No storage HA — accepted. +- **Mesh-only, no public:** asus nginx TLS proxy binds host `:443` via `hostPort` (single replica; Service ClusterIP). DNS `nextcloud.itguys.ro` → A `100.96.0.2`, DNS-only / grey-cloud. Cluster cloudflared tunnel deliberately NOT used. No ingress controller (traefik disabled). Future apps = added nginx SNI server blocks + own cert. +- **Components:** chart `nextcloud/nextcloud` (Apache img, plain HTTP Service :8080; chart nginx sidecar OFF — TLS terminated by separate front nginx, Deviation #1). Dedicated MariaDB (Postgres rejected). Dedicated Valkey for `memcache.locking`/`memcache.distributed` — fresh instance, do NOT reuse degoog's. TLS via cert-manager (ns `cert-manager`) w/ Cloudflare DNS-01 ClusterIssuer, auto-renewed (proxy self-reloads on rotation). +- **Backups ≠ replication:** nightly asus CronJob `mariadb-dump --single-transaction` (no `occ`/maintenance-mode — Deviation #2) + `config.php` copy to local PVC, then `rsync` data + dump to `acer-laptop` over Mesh SSH. Acer copy = real recovery path; local PVC only covers accidental deletion. ## Secrets policy (hard rule — repo may go to GitHub) - Never commit plaintext. `.gitignore` enforces — do NOT loosen. -- Pattern: commit `secrets/.example` (placeholders) → operator copies to `secrets/.yaml` (gitignored, real) → `kubectl apply` out-of-band. -- Five secrets: scoped Cloudflare API token (`Zone:DNS:Edit` + `Zone:Zone:Read` on `itguys.ro`; `cf-api-token`), Nextcloud admin (`nextcloud-admin`), MariaDB root + nextcloud DB (`nextcloud-db`), Valkey (`valkey-auth`), backup SSH key (`backup-ssh`). -- Non-k8s dependency: out-of-band Cloudflare Gateway "Do Not Inspect" rule for `nextcloud.itguys.ro` required (see README "Operational dependencies" / design §5). +- Source-of-truth = GitHub Actions repo Secrets. Workflow renders k8s Secret manifests at apply time. Local `secrets/*.yaml` (gitignored) only used to seed `gh secret set`. +- Commit `secrets/.example` (placeholders) as schema reference. +- CF API token scope: `Zone:DNS:Edit` + `Zone:Zone:Read` on `itguys.ro` only. +- Non-k8s dep: out-of-band Cloudflare Gateway "Do Not Inspect" rule for `nextcloud.itguys.ro` (see README "Operational dependencies" / design §5). Silent failure if removed: clients get Gateway CA cert, Android app breaks, traffic decrypted at CF edge. diff --git a/README.md b/README.md index d7501fd..dba6fc9 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,66 @@ # nextcloud (k3s homelab) -Lightweight **versioned-imperative** repo for the Nextcloud deployment on the -3-node k3s-over-Cloudflare-WARP-Mesh cluster. No GitOps controller — changes -are applied manually (`helm upgrade -f ...`, `kubectl apply -f ...`) but every -declarative artifact is version-controlled here. +CI-deployed Nextcloud stack for the 3-node k3s-over-Cloudflare-WARP-Mesh cluster. Push to `main` triggers `.github/workflows/deploy.yml` on the self-hosted ARC runner `arc-itguys-ro-nextcloud`. -Cluster operational runbook / source-of-truth: `~/cloudflare-mesh-k3s-state.md`. +Cluster operational runbook (outside repo): `~/cloudflare-mesh-k3s-state.md`. +Design: `docs/superpowers/specs/2026-05-25-nextcloud-ci-deployment-design.md`. ## Layout ``` -docs/ design spec(s) -helm/ Helm values (committed, secret-free): cert-manager + nextcloud -manifests/ raw k8s YAML: namespace, cert-manager issuer + certificate, - MariaDB, Valkey, PVCs, nginx TLS proxy (hostPort :443), backup CronJob -secrets/ templates ONLY (*.example). Real secrets are gitignored and - applied out-of-band. +.github/workflows/ deploy.yml (push:main) + pr-checks.yml (PR dry-run) +docs/ design specs +helm/ Helm values (committed, secret-free): cert-manager + nextcloud +manifests/ raw k8s YAML (CI-applied: certificate, MariaDB, Valkey, PVCs, backup CronJob) +manifests/bootstrap/ one-time, applied manually by cluster-admin (namespace, ClusterIssuer, deployer RBAC) +secrets/ *.example templates only. Real values live in GitHub Actions Secrets. ``` -## Secrets policy (read before committing anything) +`manifests/60-nginx-tls-proxy.yaml` is **not CI-applied** — it's a historical baseline of the nextcloud-only vhost. The live proxy is a shared `:443` ingress serving 4 other apps; edit it out-of-band. -**Never commit plaintext secrets.** This repo may end up on GitHub. -Excluded by `.gitignore`: everything in `secrets/` except `*.example`, plus -`*-secret.yaml`, `*.key`, `*.pem`, `kubeconfig*`, `.env*`, `*-token*`. +## Secrets -Workflow: -1. A template lives at `secrets/.example` (placeholders, committed). -2. Copy → `secrets/.yaml` (real values, **gitignored**). -3. `kubectl apply -f secrets/.yaml` out-of-band. +Plaintext **never** committed (`.gitignore` enforces). -Secrets this deployment needs (all out-of-band, never in git): -- Cloudflare API token for cert-manager DNS-01 (`Zone:DNS:Edit` + - `Zone:Zone:Read` on `itguys.ro`) — `secrets/cf-api-token`. -- Nextcloud admin password — `secrets/nextcloud-admin`. -- MariaDB root + nextcloud DB passwords — `secrets/nextcloud-db`. -- Valkey password — `secrets/valkey-auth`. -- Dedicated backup SSH private key (rsync to acer) — `secrets/backup-ssh`. +Workflow on rotation: +1. Update `secrets/.yaml` locally (gitignored). +2. `gh secret set ` for each affected key (see `.github/workflows/deploy.yml` for the GH secret names). +3. Re-run deploy (`gh workflow run deploy.yml -f reconcile_all=true`). -(If this grows, consider SOPS+age or Sealed Secrets — out of scope for now.) +GitHub Actions Secrets currently set: +`CF_API_TOKEN`, `NEXTCLOUD_ADMIN_USERNAME`, `NEXTCLOUD_ADMIN_PASSWORD`, `MARIADB_ROOT_PASSWORD`, `MARIADB_USERNAME`, `MARIADB_PASSWORD`, `VALKEY_PASSWORD`, `BACKUP_SSH_PRIVATE_KEY`. -## Apply order +The CF API token must be scoped `Zone:DNS:Edit` + `Zone:Zone:Read` on `itguys.ro` only. -Plan: `docs/superpowers/plans/2026-05-17-nextcloud-k3s-deployment.md`. -Human prereqs P1–P4 are in that plan's "Prerequisites" section. +## Bootstrap (one-time, per cluster) -1. `kubectl apply -f manifests/00-namespace.yaml` +Cluster-admin runs these once. CI cannot grant itself rights. + +1. `kubectl apply -f manifests/bootstrap/00-namespace.yaml` 2. `helm install cert-manager jetstack/cert-manager -n cert-manager --create-namespace --version v1.20.2 -f helm/cert-manager-values.yaml` -3. `kubectl apply -f secrets/cf-api-token.yaml` (out-of-band) → `kubectl apply -f manifests/10-clusterissuer-letsencrypt.yaml` -4. `kubectl apply -f manifests/20-certificate.yaml` -5. `kubectl apply -f secrets/nextcloud-db.yaml` (oob) → `kubectl apply -f manifests/30-mariadb.yaml` -6. `kubectl apply -f secrets/valkey-auth.yaml` (oob) → `kubectl apply -f manifests/40-valkey.yaml` -7. `kubectl apply -f manifests/50-nextcloud-pvcs.yaml` -8. `kubectl apply -f secrets/nextcloud-admin.yaml` (oob) → `helm install nextcloud nextcloud/nextcloud -n nextcloud --version 9.1.0 -f helm/nextcloud-values.yaml` -9. `kubectl apply -f manifests/60-nginx-tls-proxy.yaml` (proxy binds asus host :443 via hostPort) -10. Create DNS `nextcloud.itguys.ro` A → 100.96.0.2 (DNS-only); verify end-to-end. -11. `kubectl apply -f secrets/backup-ssh.yaml` (oob) → `kubectl apply -f manifests/70-backup-cronjob.yaml` - -Access: `https://nextcloud.itguys.ro` (default :443, Mesh participants only). -Upgrades: `helm upgrade nextcloud nextcloud/nextcloud -n nextcloud --version -f helm/nextcloud-values.yaml`. - -## Status - -Deployed. Plan executed: docs/superpowers/plans/2026-05-17-nextcloud-k3s-deployment.md. Access https://nextcloud.itguys.ro (Mesh only). - -## Operational dependencies (silent-failure if removed — read before touching Cloudflare/PVs) - -- **Cloudflare Gateway "Do Not Inspect" rule** — the `itguys` org has Gateway - `tls_decrypt` enabled, which TLS-MITMs `:443`. Rule id - `df440536-0b50-483d-b5d7-70cd7cbe6230` (`action: off`, - `http.conn.hostname == "nextcloud.itguys.ro"`) exempts this host so the real - Let's Encrypt cert is served end-to-end. **If this rule is deleted/disabled - the failure is silent**: clients get the Cloudflare Gateway CA, the Nextcloud - Android app breaks, and file traffic is decrypted at Cloudflare's edge. - Verify: `echo | openssl s_client -connect 100.96.0.2:443 -servername - nextcloud.itguys.ro 2>/dev/null | openssl x509 -noout -issuer` must show - `O = Let's Encrypt` (NOT `Gateway CA`). Any future app added on :443 needs - its hostname added to a Do-Not-Inspect rule. Full context: design doc - §5 amendment 2026-05-18 + `~/cloudflare-mesh-k3s-state.md`. -- **PV reclaim policy = Retain** — the bound PVs for all three claims - (`nextcloud-data`, `nextcloud-db`, `nextcloud-backups`) were patched to - `persistentVolumeReclaimPolicy: Retain` (local-path defaults to `Delete`), so - an accidental `kubectl delete pvc` does not wipe the hostPath. Disk-loss - recovery is still the nightly acer-laptop rsync (design §4); these PVs are - single-disk on asus. +3. Create initial `cloudflare-api-token` Secret in ns `cert-manager` (CI's narrow Role can `update` but not `create`): + ``` + kubectl -n cert-manager create secret generic cloudflare-api-token --from-literal=api-token= + ``` +4. `kubectl apply -f manifests/bootstrap/10-clusterissuer-letsencrypt.yaml` +5. `kubectl apply -f manifests/bootstrap/11-ci-deployer-rbac.yaml` +6. Install ARC scale set (see design doc §4.2 for the helm install command). +7. `gh secret set` for all 8 secrets (see above). +8. Live-edit the shared nginx-tls-proxy ConfigMap to add the `nextcloud.itguys.ro` vhost (if not already present). +9. DNS `nextcloud.itguys.ro` A → `100.96.0.2` (DNS-only). +10. Cloudflare Gateway "Do Not Inspect" rule for the hostname (see Operational dependencies). +11. Push to `main` → CI takes over. + +## Operating + +- Routine change: `git push origin main` → CI applies path-relevant jobs. +- Force full reconcile: `gh workflow run deploy.yml -f reconcile_all=true`. +- Drift correction: same — full reconcile re-asserts repo state. +- Rollback: `gh workflow disable deploy.yml`, then `helm rollback nextcloud -n nextcloud` manually. + +Access: `https://nextcloud.itguys.ro` (Mesh participants only). + +## Operational dependencies (silent-failure if removed) + +- **Cloudflare Gateway "Do Not Inspect" rule** — `itguys` org has Gateway `tls_decrypt` enabled. Rule id `df440536-0b50-483d-b5d7-70cd7cbe6230` (`action: off`, `http.conn.hostname == "nextcloud.itguys.ro"`) exempts this host. If deleted: clients get the Gateway CA, Nextcloud Android app breaks, file traffic decrypted at Cloudflare's edge. Verify: `echo | openssl s_client -connect 100.96.0.2:443 -servername nextcloud.itguys.ro 2>/dev/null | openssl x509 -noout -issuer` must show `O = Let's Encrypt`. Any new host on :443 needs its own rule. Full context: design doc §5 amendment 2026-05-18. +- **PV reclaim policy = Retain** — all three claims (`nextcloud-data`, `nextcloud-db`, `nextcloud-backups`) patched to `persistentVolumeReclaimPolicy: Retain` (local-path defaults to `Delete`). An accidental `kubectl delete pvc` does not wipe the hostPath. Real disk-loss recovery is the nightly acer rsync. diff --git a/docs/superpowers/specs/2026-05-25-nextcloud-ci-deployment-design.md b/docs/superpowers/specs/2026-05-25-nextcloud-ci-deployment-design.md new file mode 100644 index 0000000..3c3d3f7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-nextcloud-ci-deployment-design.md @@ -0,0 +1,216 @@ +# Design: Nextcloud CI/CD via GitHub Actions self-hosted runner + +**Date:** 2026-05-25 +**Status:** Approved — ready for implementation +**Predecessor:** `docs/2026-05-17-nextcloud-k3s-design.md` (initial manual deploy) +**Tracking:** NEXT-1 + +## 1. Goal + +Replace the manual `helm upgrade -f ...` / `kubectl apply -f ...` deploy loop with a push-to-main GitHub Actions workflow running on a self-hosted ARC runner inside the k3s cluster. Repo (`ITGuys-RO/nextcloud`) becomes the deployment trigger; merging to `main` reconciles cluster state. + +All cluster invariants from the original design survive unchanged: +- Single-namespace pin to `nodeSelector: kubernetes.io/hostname=asus-laptop` +- Mesh-only, no public ingress; nginx TLS proxy on asus `:443` hostPort +- Dedicated MariaDB + Valkey; chart-bundled subcharts off +- Nightly backup CronJob → rsync to acer +- Cloudflare Gateway Do-Not-Inspect rule (out-of-band) + +## 2. Non-goals + +- GitOps controller (Argo / Flux). Push-to-main is the trigger; the runner is the agent. No drift reconciliation outside of a triggered run. +- Multi-environment promotion (staging → prod). Single homelab; main → cluster, end of story. +- Image building. Nextcloud uses upstream images only. +- Helm chart vendoring or pinning beyond the existing values file. The chart version stays declared in the workflow (one place to bump). +- Secret rotation automation. Rotation = update GH Secret, re-run workflow. Manual cadence. + +## 3. Decisions (locked in brainstorming) + +| # | Decision | Choice | Why | +|---|---|---|---| +| D1 | Secrets in CI | Stored as GitHub Actions repo Secrets; workflow renders Secret manifests and `kubectl apply`s them | Plaintext never in git; no new in-cluster controller; accepts GitHub IAM coupling as homelab-grade trade-off | +| D2 | Trigger | `push: branches: [main]` → full apply | Simplest deployable-main model; PR review is the gate | +| D3 | Apply scope | Path-filtered jobs (`helm/**` vs `manifests/**` vs `secrets-workflow`) | Faster typical run; drift correction handled by `workflow_dispatch` of full reconcile | +| D4 | Cluster auth | In-cluster ServiceAccount with namespaced RBAC, default in-pod kubeconfig | Token never leaves cluster; minimal blast radius | +| D5 | Bootstrap | ARC scale set + SA/Role/RoleBinding + namespace + ClusterIssuer all applied manually once | One-time, requires cluster-admin; documented, not CI'd | +| D6 | Backup CronJob | Same path filter as other manifests | No asymmetry | +| D7 | First-run safety | Pre-flight: reconcile repo vs live cluster before flipping the switch | First CI run should be a near-no-op; surfaces drift the operator must merge into repo first | + +## 4. Architecture + +### 4.1 Repository layout (after restructure) + +``` +.github/ + workflows/ + deploy.yml # push:main → path-filtered apply + pr-checks.yml # PR → helm template + kubectl diff (no apply) + +manifests/ + bootstrap/ # NEW: applied once, by hand, by cluster-admin + 00-namespace.yaml (moved from manifests/) + 10-clusterissuer-letsencrypt.yaml (moved from manifests/) + 11-ci-deployer-rbac.yaml (NEW: SA + Role + RoleBinding) + 20-certificate.yaml (unchanged paths) + 30-mariadb.yaml + 40-valkey.yaml + 50-nextcloud-pvcs.yaml + 60-nginx-tls-proxy.yaml + 70-backup-cronjob.yaml + +helm/ # unchanged +secrets/ # *.example only; *.yaml stays gitignored, no longer applied +docs/ # this spec +``` + +### 4.2 ARC runner (one-time bootstrap) + +Repo-scoped scale set `arc-itguys-ro-nextcloud` in ns `arc-runners`, matching the established pattern (`arc-itguys-ro-degoog-infra`, `arc-itguys-ro-fleet-manager`, …): + +- Chart: `oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set` v0.14.1 +- `containerMode: { type: dind }` +- `githubConfigSecret: github-app-itguys` (already exists in cluster) +- `githubConfigUrl: https://github.com/ITGuys-RO/nextcloud` +- `minRunners: 0`, `maxRunners: 2` +- Listener + runner pods pinned to `asus-laptop` (wsl outbound flaky; taint imperative-only) + +Repo-scoped (not the org-scoped `arc-itguys-ro` set) because GitHub-side runner-group routing for org-owned repos has not been resolved (jobs sit unrouted). Matches the workaround documented in degoog's K3S_MIGRATION_PLAN.md. + +### 4.3 ServiceAccount + RBAC (`manifests/bootstrap/11-ci-deployer-rbac.yaml`) + +- `ServiceAccount/nextcloud-deployer` in ns `nextcloud` +- `Role/nextcloud-deployer` in ns `nextcloud` — verbs `get,list,watch,create,update,patch,delete` on the chart's resource kinds: `deployments,statefulsets,replicasets,pods,services,configmaps,secrets,persistentvolumeclaims,jobs,cronjobs,serviceaccounts,roles,rolebindings,ingresses,certificates.cert-manager.io`. Plus `events` get/list/watch for `kubectl rollout status` and helm hooks. +- `RoleBinding/nextcloud-deployer` binding the SA to the Role. +- **No ClusterRole.** The SA cannot create/modify the Namespace, ClusterIssuer, or anything outside ns `nextcloud`. Those stay in `bootstrap/`. + +The runner pod inside the cluster uses its own SA by default. To make the deploy job run as `nextcloud-deployer`, the workflow uses a one-time `kubectl --token=$(cat /var/run/secrets/...)` is not necessary — instead, the ARC runner pod spec mounts a *projected* token for `nextcloud-deployer` via the workflow step using `kubectl --as=system:serviceaccount:nextcloud:nextcloud-deployer` impersonation. ARC's default runner SA must permit impersonation of `nextcloud-deployer` only, via a narrow ClusterRole `nextcloud-deployer-impersonator` bound to the ARC runner SA in `arc-runners`. This stays in `manifests/bootstrap/11-ci-deployer-rbac.yaml`. + +(Alternative considered: mount a kubeconfig secret. Rejected — adds a secret to rotate. Impersonation keeps the credential in cluster.) + +### 4.4 Secrets (D1) + +GitHub Actions repo Secrets (set out-of-band via `gh secret set`): + +| GH Secret | Renders into k8s Secret | Key(s) | +|---|---|---| +| `CF_API_TOKEN` | `cloudflare-api-token` (ns `cert-manager`) | `api-token` | +| `NEXTCLOUD_ADMIN_USERNAME` (default `admin`) + `NEXTCLOUD_ADMIN_PASSWORD` | `nextcloud-admin` (ns `nextcloud`) | `nextcloud-username`, `nextcloud-password` | +| `MARIADB_ROOT_PASSWORD` + `MARIADB_USERNAME` (default `nextcloud`) + `MARIADB_PASSWORD` | `nextcloud-db` (ns `nextcloud`) | `mariadb-root-password`, `db-username`, `db-password` | +| `VALKEY_PASSWORD` | `valkey-auth` (ns `nextcloud`) | `redis-password` | +| `BACKUP_SSH_PRIVATE_KEY` | `backup-ssh` (ns `nextcloud`) | `id_ed25519` | + +**Special case — `cloudflare-api-token` is in ns `cert-manager`.** The `nextcloud-deployer` SA has no access there. The workflow handles this by applying the CF secret using a separate job running as a second SA `cf-token-rotator` in ns `cert-manager`, restricted to `get,patch,update` on Secrets named `cloudflare-api-token` only (via `resourceNames`). That ClusterRole + RoleBinding ships in `manifests/bootstrap/11-ci-deployer-rbac.yaml`. + +Secrets workflow strategy: secrets are applied on every workflow run (cheap, idempotent — `kubectl apply` with unchanged content is a no-op). No drift detection. + +### 4.5 Workflow: `deploy.yml` + +``` +on: + push: + branches: [main] + paths: + - 'helm/**' + - 'manifests/**' + - '.github/workflows/deploy.yml' + workflow_dispatch: + inputs: + reconcile_all: + description: "Apply everything (helm + manifests + secrets) regardless of paths" + type: boolean + default: false + +concurrency: + group: deploy + cancel-in-progress: false # never cancel an in-flight apply +``` + +**Jobs:** + +1. **`detect-changes`** — `dorny/paths-filter` to set outputs: `helm`, `manifests`, `secrets` (always true; runs every workflow). +2. **`apply-secrets`** (always runs) — renders all 5 Secret manifests from GH Secrets via `envsubst` on heredoc templates, `kubectl apply -f -`. Uses `--as=system:serviceaccount:cert-manager:cf-token-rotator` for the CF token; default SA (nextcloud-deployer impersonation) for the rest. +3. **`apply-manifests`** — `if: needs.detect-changes.outputs.manifests == 'true' || inputs.reconcile_all`. Runs `kubectl apply -f manifests/ --recursive=false` (top-level only; bootstrap/ excluded). Streams `kubectl rollout status` per Deployment that changed. +4. **`helm-upgrade`** — `if: needs.detect-changes.outputs.helm == 'true' || inputs.reconcile_all`. Runs `helm upgrade --install nextcloud nextcloud/nextcloud --version -f helm/nextcloud-values.yaml -n nextcloud --atomic --timeout 5m`. +5. **`smoke-test`** (depends on apply-* + helm-upgrade) — `curl -k https://nextcloud.itguys.ro/status.php` from inside the runner pod via the Mesh; asserts HTTP 200 and `installed=true` in JSON. Fails the build on regression. + +All jobs `runs-on: arc-itguys-ro-nextcloud`. No `actions/checkout` token rotation needed (default `GITHUB_TOKEN` read-only is fine — no pushes back to the repo). Third-party actions pinned by SHA (matches degoog convention). + +### 4.6 Workflow: `pr-checks.yml` + +``` +on: + pull_request: + paths: ['helm/**','manifests/**','.github/workflows/deploy.yml','.github/workflows/pr-checks.yml'] +``` + +Single job, same runner: +- `helm template nextcloud nextcloud/nextcloud --version -f helm/nextcloud-values.yaml | kubectl apply --dry-run=server -f -` +- `kubectl apply --dry-run=server -f manifests/` +- `kubectl diff -f manifests/ || true` (advisory, doesn't fail PR — diff output posted in step log) + +No secret access from PR runs. Read-only RBAC SA: same `nextcloud-deployer` token suffices because `apply --dry-run=server` only needs the verbs the SA already has. + +**Fork PRs:** repo is private under `ITGuys-RO`. Fork PRs treated as not-trusted by default GitHub rules; `pull_request_target` is NOT used (no need; no public collaborators). Standard `pull_request` event only. + +### 4.7 Data flow + +``` +Operator → git push main + │ + ▼ +GitHub Actions (deploy.yml) + │ + ▼ +arc-itguys-ro-nextcloud runner pod (in cluster, asus-pinned) + │ + │ default SA: ServiceAccount/nextcloud-arc-runner (arc-runners ns) + │ impersonates: nextcloud-deployer (nextcloud) OR cf-token-rotator (cert-manager) + ▼ +k3s API server + │ + ▼ +ns nextcloud (helm-managed + raw manifests) + ns cert-manager (CF token only) +``` + +No outbound. The runner reaches the API server via `kubernetes.default.svc.cluster.local:443` (in-cluster). + +## 5. Error handling + +- **Helm upgrade fails:** `--atomic --timeout 5m` rolls back to last good release. Workflow step exits non-zero; smoke-test still runs and likely fails too (clear signal). +- **kubectl apply fails mid-batch:** apply is per-file; `kubectl rollout status` after each Deployment surfaces stuck pods within 60s. Job fails. Operator inspects via headlamp / `kubectl describe`. +- **Smoke test 5xx:** workflow red. Last-good release stays running (atomic). Operator triages. +- **ARC runner pod doesn't show up:** ARC listener pod in `arc-runners` logs the listener registration error. Workflow shows `queued` for >10 min → operator checks `kubectl -n arc-runners get pods` and the listener logs. +- **Secret rendering produces empty value:** `envsubst` template uses `${VAR?missing}` so an unset GH secret aborts the step rather than writing an empty Secret. + +## 6. Testing + +Unit-test scope = nothing (no app code). Integration test = the smoke-test job. + +Pre-merge: PR check confirms `helm template` + `kubectl --dry-run=server` succeed. +Post-merge: smoke test asserts the live endpoint. + +Manual verification gates: +- After bootstrap: `kubectl auth can-i ... --as=system:serviceaccount:...` for each verb the workflow uses. +- After first CI run: confirm `helm history nextcloud -n nextcloud` shows release marked Deployed without rolling back. + +## 7. Migration sequence + +Listed in order; each is a separate task in the implementation plan. + +1. **Pre-flight reconciliation** (T2 in plan). `helm get values nextcloud -n nextcloud` vs `helm/nextcloud-values.yaml`; `kubectl diff -f manifests/` against live. Resolve any drift into the repo. First CI run must be a near-no-op. +2. **Restructure manifests/** (T3). git-mv 00 and 10 into `bootstrap/`. Update `README.md` apply order. +3. **Author RBAC manifest** (T4). Commit only — no apply yet. +4. **Author workflows** (T5, T6). Commit only. +5. **Bootstrap ARC + RBAC** (T7). `helm install arc-itguys-ro-nextcloud ...`; `kubectl apply -f manifests/bootstrap/11-ci-deployer-rbac.yaml`. Verify runner registers with GitHub. +6. **Populate GH Secrets** (T8). `gh secret set` for each, sourced from the local `secrets/*.yaml` files (still gitignored). +7. **Update docs** (T9). `README.md` apply-order section flipped to "Updates are pushed to main; CI reconciles." `CLAUDE.md` "no GitOps controller; manual `helm upgrade`" line replaced with the new model. +8. **Cutover** (T10). Open PR `feat/github-actions-deploy` → rebase-merge (rebase-only org rule). First CI run = no-op confirmation. If green, the goal is achieved. + +## 8. Rollback + +- Disable workflows: `gh workflow disable deploy.yml`. The cluster keeps running whatever was last applied — nothing is removed. Operator reverts to manual `helm upgrade`. +- The bootstrap manifests (SA, RBAC, ARC scale set) can be left in place; they cost nothing while idle (minRunners=0). + +## 9. Open questions + +None at design time. Decisions D1–D7 settle the substantive forks; everything else is mechanical execution. diff --git a/manifests/60-nginx-tls-proxy.yaml b/manifests/60-nginx-tls-proxy.yaml index e88d13c..d92f567 100644 --- a/manifests/60-nginx-tls-proxy.yaml +++ b/manifests/60-nginx-tls-proxy.yaml @@ -1,3 +1,9 @@ +# NOT APPLIED BY CI. The live nginx-tls-proxy on asus-laptop is the shared +# :443 ingress for multiple Mesh apps (headlamp, degoog, searxng, degoog-mcp). +# Live state has additional vhosts and a second cert mount (/tls-warp, secret +# mesh-warp-tls) that this file does NOT declare. Editing the live proxy is +# an out-of-band operation. This file is a historical baseline of the +# nextcloud-only vhost; do NOT `kubectl apply` it as-is. apiVersion: v1 kind: ConfigMap metadata: diff --git a/manifests/README.md b/manifests/README.md index a35b6ba..6bb6e21 100644 --- a/manifests/README.md +++ b/manifests/README.md @@ -1,12 +1,32 @@ # manifests/ -Raw Kubernetes YAML applied to the cluster. Files (apply order in root README): +Raw Kubernetes YAML. + +## CI-applied (push to main → `.github/workflows/deploy.yml`) + +Applied by the `nextcloud-deployer` ServiceAccount in ns `nextcloud`: -- `00-namespace.yaml` -- `10-clusterissuer-letsencrypt.yaml` - `20-certificate.yaml` - `30-mariadb.yaml` - `40-valkey.yaml` - `50-nextcloud-pvcs.yaml` -- `60-nginx-tls-proxy.yaml` - `70-backup-cronjob.yaml` + +## Informational only — NOT applied by CI + +- `60-nginx-tls-proxy.yaml` — shared `:443` ingress on `asus-laptop` also serves + `headlamp.itguys.ro`, `degoog.itguys.ro`, `searxng.itguys.ro`, + `degoog-mcp.itguys.ro`. Live state has additional vhosts and a second cert + mount (`/tls-warp`, secret `mesh-warp-tls`) not declared here. Managed + out-of-band. The repo copy is a historical baseline of the nextcloud-only + vhost; do not `kubectl apply` it as-is. + +## bootstrap/ (one-time, applied manually by cluster-admin) + +- `bootstrap/00-namespace.yaml` — creates ns `nextcloud`. +- `bootstrap/10-clusterissuer-letsencrypt.yaml` — cluster-scoped, references + `cloudflare-api-token` in ns `cert-manager`. +- `bootstrap/11-ci-deployer-rbac.yaml` — `nextcloud-deployer` SA + Role + + RoleBinding in ns `nextcloud`, plus `cf-token-rotator` SA + ClusterRole + + RoleBinding in ns `cert-manager`, plus the ARC runner SA impersonation + binding. diff --git a/manifests/00-namespace.yaml b/manifests/bootstrap/00-namespace.yaml similarity index 100% rename from manifests/00-namespace.yaml rename to manifests/bootstrap/00-namespace.yaml diff --git a/manifests/10-clusterissuer-letsencrypt.yaml b/manifests/bootstrap/10-clusterissuer-letsencrypt.yaml similarity index 100% rename from manifests/10-clusterissuer-letsencrypt.yaml rename to manifests/bootstrap/10-clusterissuer-letsencrypt.yaml diff --git a/manifests/bootstrap/11-ci-deployer-rbac.yaml b/manifests/bootstrap/11-ci-deployer-rbac.yaml new file mode 100644 index 0000000..e0d1284 --- /dev/null +++ b/manifests/bootstrap/11-ci-deployer-rbac.yaml @@ -0,0 +1,102 @@ +# RBAC for the GitHub Actions self-hosted runner (ARC scale set +# `arc-itguys-ro-nextcloud`). The runner pod's default SA is +# `arc-itguys-ro-nextcloud-gha-rs-no-permission` in ns `arc-runners` +# (named per the gha-runner-scale-set chart convention). +# +# This binds that SA to two Roles: +# - `nextcloud-deployer` in ns `nextcloud` → full CRUD on chart + manifest resources +# - `cf-token-writer` in ns `cert-manager` → narrow update of one Secret +# +# Applied once by cluster-admin. Not CI-managed (CI can't grant itself rights). +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: nextcloud-deployer + namespace: nextcloud +rules: + - apiGroups: [""] + resources: + - pods + - pods/log + - services + - configmaps + - secrets + - persistentvolumeclaims + - serviceaccounts + - events + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["apps"] + resources: + - deployments + - statefulsets + - replicasets + - daemonsets + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["batch"] + resources: + - jobs + - cronjobs + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: + - roles + - rolebindings + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["networking.k8s.io"] + resources: + - ingresses + - networkpolicies + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["policy"] + resources: + - poddisruptionbudgets + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["cert-manager.io"] + resources: + - certificates + - certificaterequests + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: nextcloud-deployer + namespace: nextcloud +subjects: + - kind: ServiceAccount + name: arc-itguys-ro-nextcloud-gha-rs-no-permission + namespace: arc-runners +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: nextcloud-deployer +--- +# Narrow Role in ns cert-manager so the runner can update ONE Secret +# (`cloudflare-api-token`) used by the ClusterIssuer DNS-01 solver. +# Secret is pre-created by the cluster-admin during bootstrap; CI only +# updates content on rotation. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: cf-token-writer + namespace: cert-manager +rules: + - apiGroups: [""] + resources: ["secrets"] + resourceNames: ["cloudflare-api-token"] + verbs: ["get", "update", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: cf-token-writer + namespace: cert-manager +subjects: + - kind: ServiceAccount + name: arc-itguys-ro-nextcloud-gha-rs-no-permission + namespace: arc-runners +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: cf-token-writer