Phase-6: enable Gitea Actions, in-cluster act_runner, static CI gate

STEP 1 — enable Gitea Actions: pin GITEA__actions__ENABLED=true and
DEFAULT_ACTIONS_URL=github in the gitea bootstrap patch (was implicit upstream
default; make it a declared property).

STEP 2 — deploy + register an act_runner in a dedicated non-app namespace
(gitea-actions) so the privileged DinD executor sidecar is admitted without a
Kyverno policy exception (restrict-host-namespaces scopes to app namespaces only).
Docker executor + DinD sidecar over a shared TLS-cert emptyDir; runner state on a
Longhorn PVC (registers once). Registration token delivered as a SOPS-encrypted
Secret decrypted by Flux. Runner mounts no ServiceAccount token (R-SEC-02).

STEP 3 — author .gitea/workflows/gate.yml: the live enforced merge gate running
the STATIC layers (spec-immutability best-effort, lint, Kyverno L0, L1 render) via
`make -C ci lint l0 l1` with the pinned toolchain installed at job start. L2/L3
conformance is an if:false placeholder (needs a toolchain image + scoped cluster
creds — a design decision, not wired). merge-gate.yaml reduced to workflow_dispatch
(dormant full-pipeline blueprint). install-tools.sh gains check-jsonschema so the
schema stage validates in CI instead of skipping.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 4.8 committed 2026-08-25 23:51:52 -05:00
1 parent 81ef78295a
commit bc4519bc4c
7 files changed
+327 -10

No files matched your search

@@ -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
+169
View File
@@ -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: {}
+13
View File
@@ -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: <org>/<action>@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:
+2
View File
@@ -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