diff --git a/.gitea/workflows/gate.yml b/.gitea/workflows/gate.yml new file mode 100644 index 0000000..442a575 --- /dev/null +++ b/.gitea/workflows/gate.yml @@ -0,0 +1,89 @@ +# .gitea/workflows/gate.yml — the ENFORCED PR merge gate (Phase 6, WS-GITOSS). +# +# This is the LIVE gate wired to the in-cluster Gitea Actions runner (ns gitea-actions, +# Docker executor + DinD). It runs the STATIC layers of the conformance gate — the ones +# docs/14 says run "without a cluster": spec-immutability (best-effort), lint, Kyverno L0, +# and L1 offline render checks. It is the single required status check on `main` +# (see branch protection). `make -C ci lint l0 l1` here is byte-identical to a laptop run +# (R-TEST-02) — the runner just installs the pinned tools from ci/versions.env first. +# +# The L2/L3 CONFORMANCE layer (behavioural + fault-injection, needs a live cluster) is +# NOT wired yet — it is a deliberate design decision (a CI toolchain image + a scoped +# in-cluster kubeconfig/RBAC for the runner). It is present below as an `if: false` +# placeholder so the shape is visible and the wiring is a one-line flip once decided. +# The full pipeline blueprint (spec-checkout, per-layer jobs, decision job) is retained +# dormant in merge-gate.yaml. +--- +name: gate + +on: + pull_request: + branches: [main] + push: + branches: + - main + - 'feature/**' + - 'fix/**' + - 'docs/**' + - 'chore/**' + +concurrency: + group: gate-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Single required status check: the static merge gate. + gate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history so ci/touched-requirements.sh can diff vs main + + - name: Install pinned CI toolchain (ci/versions.env) + run: | + ./.gitea/workflows/scripts/install-tools.sh l0 + ./.gitea/workflows/scripts/install-tools.sh l1 + + # R-TEST-06: the spec is read-only to agents. The spec repo is NOT checked out into + # CI here (it is a separate repo; SPEC_REPO_SLUG is not wired into this runner), so + # the guard honestly SKIPS (exit 3) rather than faking green. A real drift/edit of a + # co-located spec would exit 1 and fail. Tolerated: exit 3 only. + - name: Spec immutability (R-TEST-06, skips if spec repo absent) + run: make -C ci spec-immutability || [ "$?" -eq 3 ] + + - name: Static gate — lint + Kyverno L0 + L1 render + run: make -C ci lint l0 l1 + + - name: Upload gate artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: gate-junit-coverage + path: | + results/junit/*.xml + reports/coverage/** + if-no-files-found: ignore + + # ─────────────────────────────────────────────────────────────────────────── + # PLACEHOLDER — NOT WIRED (STOP-and-report item, WS-GITOSS Phase 6). + # L2/L3 conformance (Chainsaw behavioural + fault-injection) needs a LIVE cluster. + # Turning this on is a design decision with two open sub-decisions: + # 1. Toolchain: build+push a pinned CI image to the Gitea container registry and + # reference it as the job image, vs. install-in-job from ci/versions.env (slower). + # 2. Cluster creds: how the runner reaches the cluster — the intended model is an + # in-cluster Chainsaw Job driven by its OWN scoped ServiceAccount (CI holds only a + # narrow trigger credential; the runner mounts NO SA token today). That SA + RBAC + # + the trigger secret do not exist yet. + # Kept `if: false` so it never runs (shows skipped, never fakes green — HARD RULE 4). + # ─────────────────────────────────────────────────────────────────────────── + conformance: + runs-on: ubuntu-latest + needs: [gate] + if: false + steps: + - name: Placeholder + run: | + echo "L2/L3 conformance not wired — see gate.yml header + WS-GITOSS report." >&2 + exit 1 diff --git a/.gitea/workflows/merge-gate.yaml b/.gitea/workflows/merge-gate.yaml index dc5e06b..48f4f52 100644 --- a/.gitea/workflows/merge-gate.yaml +++ b/.gitea/workflows/merge-gate.yaml @@ -20,18 +20,23 @@ # CI holds NO broad kubeconfig. L2/L3 run as an in-cluster Chainsaw Job whose own # scoped ServiceAccount holds cluster access (docs/14, R-SEC-02); CI uses only a # narrow trigger credential referenced BY SECRET NAME below. +# +# ───────────────────────────────────────────────────────────────────────────── +# DORMANT (Phase 6, WS-GITOSS): the LIVE enforced gate is now gate.yml. This file is +# retained as the full-pipeline BLUEPRINT (spec-checkout via SPEC_REPO_SLUG + the L2/L3 +# conformance job + the R-TEST-03 decision job) for when those seams are wired: +# - SPEC_REPO_SLUG must be set as a Gitea repo/org variable AND the spec repo hosted on +# this Gitea, or spec-immutability's second checkout fails the whole run. +# - vars.CLUSTER_AVAILABLE + a scoped conformance trigger credential must exist. +# Until then it must NOT auto-run (its spec checkout would fail with SPEC_REPO_SLUG unset), +# so its triggers are reduced to manual `workflow_dispatch`. Re-enable by restoring the +# pull_request/push triggers once the seams above are wired. +# ───────────────────────────────────────────────────────────────────────────── --- name: merge-gate on: - pull_request: - branches: [main] # PRs are the enforced merge gate into main (CONTRIBUTING.md) - push: - branches: - - 'feature/**' # feature-branch -> main workflow (no work on main) - - 'fix/**' - - 'docs/**' - - 'chore/**' + workflow_dispatch: {} # DORMANT blueprint — manual-only; gate.yml is the live gate concurrency: group: merge-gate-${{ github.ref }} diff --git a/.gitea/workflows/scripts/install-tools.sh b/.gitea/workflows/scripts/install-tools.sh index 8e3ddd3..3989c45 100755 --- a/.gitea/workflows/scripts/install-tools.sh +++ b/.gitea/workflows/scripts/install-tools.sh @@ -19,6 +19,8 @@ log() { printf '[install] %s\n' "$*" >&2; } install_yamllint() { have yamllint || { log "yamllint ${YAMLLINT_VERSION}"; pip install --quiet "yamllint==${YAMLLINT_VERSION}"; }; } +install_check_jsonschema() { have check-jsonschema || { log "check-jsonschema ${CHECK_JSONSCHEMA_VERSION}"; pip install --quiet "check-jsonschema==${CHECK_JSONSCHEMA_VERSION}"; }; } + install_kyverno() { have kyverno && return 0 log "kyverno CLI ${KYVERNO_VERSION}" @@ -44,10 +46,10 @@ have() { command -v "$1" >/dev/null 2>&1; } case "$stage" in lint) install_yamllint ;; - l0) install_yamllint; install_kyverno ;; + l0) install_yamllint; install_check_jsonschema; install_kyverno ;; # schema (check-jsonschema) + Kyverno L0 l1) install_chainsaw ;; # L1 render via conformance runner (Chainsaw) conformance) install_chainsaw; install_kubectl ;; # trigger the in-cluster Job - all) install_yamllint; install_kyverno; install_chainsaw; install_kubectl ;; + all) install_yamllint; install_check_jsonschema; install_kyverno; install_chainsaw; install_kubectl ;; *) echo "unknown stage: $stage" >&2; exit 2 ;; esac log "stage '${stage}' tools ready" diff --git a/apps/gitea-actions-runner-secret.sops.yaml b/apps/gitea-actions-runner-secret.sops.yaml new file mode 100644 index 0000000..48e0e83 --- /dev/null +++ b/apps/gitea-actions-runner-secret.sops.yaml @@ -0,0 +1,37 @@ +# Gitea Actions runner registration token (WS-GITOSS, Phase 6). SOPS-encrypted (age). +# The in-cluster act_runner (ns gitea-actions) uses this INSTANCE-level registration +# token (from POST /api/v1/admin/actions/runners/registration-token) to register once +# against Gitea. Flux decrypts it via the sops-age key at reconcile (apps Kustomization +# decryption is wired in clusters/dev/kustomization.yaml). Rotate by resetting the token +# in Gitea (admin -> actions -> runners) and re-encrypting a fresh value here. +# Recover/inspect: infra/devcontainer/dev.sh run sops -d apps/gitea-actions-runner-secret.sops.yaml +apiVersion: v1 +kind: Secret +metadata: + name: gitea-actions-runner + namespace: gitea-actions + labels: + app.kubernetes.io/name: act-runner +type: Opaque +stringData: + token: ENC[AES256_GCM,data:W48mEtBqGeukX13lMhrhrer+zB21cLVp0acy3m1Xy/oo89DqO8FX/Q==,iv:wfTUU7RiFLdEc1fmEFS9DfE2SmGCFLPK17m0IKspft0=,tag:UhoXA47p7386KHcYNbPzCg==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMYjhDVis5UzBvMWdjQUtk + T28wRTFZdjB6NFZOYmxQTGpOcksrb2pyK1RNCjZVdnFHU0IvOEcvcmRjZ05SQWI0 + OTdEekJDYlc3cUxNU29OZU9YWlM5b00KLS0tIEtNeHlrV3NXUnhyNnJncW5vZmta + MDZLUE52QzJXbFlrNWxOcVhJbDNnY00KxCpFkbhhCuk4Fa8CC3C4+R9AcsUnaXQR + UjAfwKFoXAneXUih7TTXMbR/6+VgQBww5Bmwpn7jVLnzP0N0pIZVTA== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-08-26T04:48:14Z" + mac: ENC[AES256_GCM,data:b99/zkL332RIT3FYBa1sR3o9+pwlmrUgKvVHawbjWYSJZ7ERUfO+LXuhKJ0HKrp6i1JfkurZlRO27iBcfrBisAXS9KLaqmsCJf4kKfFc4skANrmKCzprrXUmZexr1uiE8eDUzjNCLNxSCZfnFR6d/AkMXJqi6Hsj9/O+wH2lWsc=,iv:3DIPszSdxK81ERO3o/nTs33qQYHaAKLNcGP4qlZViFc=,tag:/B6VKkzMUwh0byNUicS5wQ==,type:str] + pgp: [] + encrypted_regex: ^(data|stringData)$ + version: 3.9.4 diff --git a/apps/gitea-actions.yaml b/apps/gitea-actions.yaml new file mode 100644 index 0000000..b91c529 --- /dev/null +++ b/apps/gitea-actions.yaml @@ -0,0 +1,169 @@ +--- +# Gitea Actions runner (act_runner) — Phase 6 CI/CD (WS-GITOSS). +# +# WHY A DEDICATED, NON-APP NAMESPACE: +# The runner's job executor is a Docker-in-Docker (DinD) sidecar that MUST run +# `privileged: true` (dockerd needs it). The Kyverno `restrict-host-namespaces` +# policy denies privileged containers, but it (and the egress-class / auth-mode / +# default-deny-baseline policies) scope ONLY to namespaces labeled +# homelab.keithjackson.dev/app-namespace: "true". This namespace deliberately does +# NOT carry that label — it is CI plumbing, not a user-facing app — so the privileged +# DinD sidecar is admitted WITHOUT a policy exception. (Verified: a privileged Pod in +# a non-app namespace passes server-side admission; Kyverno test fixture +# `platform-workload` is likewise Excluded from restrict-host-namespaces.) +# +# EXECUTOR MODEL — Docker executor + DinD sidecar (privileged): +# act_runner runs each job step inside a container it launches via a Docker daemon. +# That daemon is the co-located `dind` sidecar (shared pod netns → tcp://localhost:2376, +# TLS via a shared emptyDir of certs). Tradeoff vs the host/self-hosted executor: DinD +# needs `privileged`, but it gives clean per-job isolation and lets the workflow pick its +# own job image (the pinned toolchain), which the host executor cannot. Privileged is +# contained to this CI-only namespace and the runner mounts NO ServiceAccount token +# (automountServiceAccountToken: false) — CI holds no cluster credentials (R-SEC-02). +apiVersion: v1 +kind: Namespace +metadata: + name: gitea-actions + labels: + # NOTE: intentionally NO homelab.keithjackson.dev/app-namespace label (see header). + homelab.keithjackson.dev/ci: act-runner +--- +# Default-deny ingress; broad egress (a CI runner legitimately needs the internet: +# ghcr.io / github.com / docker hub / dl.k8s.io / pypi for job images + pinned tools, +# and the Gitea ingress VIP for registration + repo clone). Nothing connects INTO the +# runner, so ingress is denied entirely. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-ingress-allow-egress + namespace: gitea-actions + labels: + homelab.keithjackson.dev/baseline: default-deny +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress + ingress: [] + egress: + # DNS (cluster + split-horizon resolution of gitea.home.keithjackson.dev). + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # Everything else the runner + DinD need: Gitea VIP + internet registries/tool hosts. + - {} +--- +# Runner state (config.yaml + the `.runner` registration file) persists here so the +# runner registers exactly ONCE and reuses its identity across restarts (no duplicate +# offline rows in Gitea). RWO → Deployment strategy must be Recreate (fix/recreate-strategy-rwo). +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: act-runner-data + namespace: gitea-actions + labels: + app.kubernetes.io/name: act-runner +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: act-runner + namespace: gitea-actions + labels: + app.kubernetes.io/name: act-runner +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: act-runner + template: + metadata: + labels: + app.kubernetes.io/name: act-runner + spec: + # CI holds no cluster credentials — do not mount a SA token into the runner or + # (via the shared netns) the job containers (R-SEC-02, docs/14). + automountServiceAccountToken: false + containers: + # ---- act_runner: registers with Gitea, dispatches jobs to the DinD daemon ---- + - name: runner + image: gitea/act_runner:0.2.11 + env: + - name: GITEA_INSTANCE_URL + value: https://gitea.home.keithjackson.dev + - name: GITEA_RUNNER_REGISTRATION_TOKEN + valueFrom: + secretKeyRef: + name: gitea-actions-runner + key: token + - name: GITEA_RUNNER_NAME + value: incluster-dind + # runs-on label -> job image. Both map to the catthehacker "act" image, which + # carries bash/git/curl/tar/python3+pip/node so .gitea/workflows scripts can + # install the pinned CI toolchain (ci/versions.env) and actions/checkout runs. + - name: GITEA_RUNNER_LABELS + value: "ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,homelab:docker://ghcr.io/catthehacker/ubuntu:act-22.04" + # Reach the DinD daemon over the shared pod network namespace, TLS-verified + # against the certs the sidecar drops into the shared /certs emptyDir. + - name: DOCKER_HOST + value: tcp://localhost:2376 + - name: DOCKER_CERT_PATH + value: /certs/client + - name: DOCKER_TLS_VERIFY + value: "1" + volumeMounts: + - name: data + mountPath: /data + - name: certs + mountPath: /certs + readOnly: true + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + memory: 512Mi + # ---- Docker-in-Docker: the job executor daemon (privileged) ---- + - name: dind + image: docker:27-dind + securityContext: + privileged: true + env: + # Generate + publish TLS certs into the shared emptyDir; dockerd then listens + # on tcp://0.0.0.0:2376 (TLS) which the runner reaches at localhost:2376. + - name: DOCKER_TLS_CERTDIR + value: /certs + volumeMounts: + - name: certs + mountPath: /certs + - name: dind-storage + mountPath: /var/lib/docker + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 2Gi + volumes: + - name: data + persistentVolumeClaim: + claimName: act-runner-data + - name: certs + emptyDir: {} + - name: dind-storage + emptyDir: {} diff --git a/apps/gitea-bootstrap-patch.yaml b/apps/gitea-bootstrap-patch.yaml index ab11d0f..83cd435 100644 --- a/apps/gitea-bootstrap-patch.yaml +++ b/apps/gitea-bootstrap-patch.yaml @@ -18,6 +18,15 @@ # it from a committed SOPS secret pins it across pod restarts/reschedules. # - GITEA__server__ROOT_URL / __DOMAIN -> correct public clone/redirect URLs for the git host # that TLS-terminates at the shared Gateway (gitea.home.keithjackson.dev). +# - GITEA__actions__ENABLED=true -> turn on Gitea Actions explicitly (Phase 6 CI/CD, WS-GITOSS). +# Gitea 1.21+ defaults Actions ON, but pinning it here makes the CI merge gate a declared, +# reviewable property of the deployment rather than an implicit upstream default that a future +# version bump could flip. The in-cluster act_runner (ns gitea-actions) registers against it. +# - GITEA__actions__DEFAULT_ACTIONS_URL=github -> resolve `uses: /@ref` (e.g. +# actions/checkout) from github.com. The cluster HAS internet egress (LE/Cloudflare/ghcr already +# in use), so github-sourced actions work. A fully self-hosted/air-gapped value would require +# mirroring each action repo into this Gitea first; the cluster is not air-gapped, so `github` +# is the pragmatic, working choice (WS-GITOSS Phase-6 note). # - A postStart lifecycle hook creates a BREAK-GLASS local admin idempotently (only if absent), # so an IdP outage never locks the operator out of recovery tools (docs/15 availability caveat). # All state (repos, SQLite DB, app.ini, INTERNAL_TOKEN) lands on the /data Longhorn PVC. @@ -43,6 +52,10 @@ spec: value: gitea.home.keithjackson.dev - name: GITEA__server__ROOT_URL value: https://gitea.home.keithjackson.dev/ + - name: GITEA__actions__ENABLED + value: "true" + - name: GITEA__actions__DEFAULT_ACTIONS_URL + value: github - name: GITEA_ADMIN_USERNAME valueFrom: secretKeyRef: diff --git a/apps/kustomization.yaml b/apps/kustomization.yaml index eb3837d..cc042ab 100644 --- a/apps/kustomization.yaml +++ b/apps/kustomization.yaml @@ -9,6 +9,8 @@ resources: - pihole.yaml - gitea.yaml - gitea-bootstrap-secret.sops.yaml # SOPS: fixed SECRET_KEY + break-glass admin creds (decrypted by Flux) + - gitea-actions.yaml # Phase 6 CI/CD: in-cluster act_runner (ns gitea-actions) + DinD + - gitea-actions-runner-secret.sops.yaml # SOPS: act_runner registration token (decrypted by Flux) - sonarr.yaml - radarr.yaml - plex.yaml