chore: seed gitea-repo-template cookie-cutter
Some checks failed
build-and-deploy / test (push) Failing after 2m36s
build-and-deploy / build-deploy (push) Has been skipped

Reusable repo template for homelab services on git.aridgwayweb.com.

Bakes in (all verified live on armistace/wedding-photos):
- Hard commit guard: shared pre-commit hook (git config core.hooksPath
  ~/dev/git-hooks) + master branch protection (push whitelist [armistace],
  merge whitelist [hermes, armistace]).
- Gitea Actions CI (.gitea/workflows/build_push.yml): test + build-deploy,
  persistent remote buildkit cache, registry push, idempotent deploy that
  preserves hand-provisioned Secrets, cluster injection from repo secrets/vars
  via scripts/reconcile-cluster-inject.sh.
- Persistent buildkit cache (ci/buildkit/): single-replica Longhorn backing.
- scripts/reconcile-cluster-inject.sh: reconcile live Secret/ConfigMap from
  Gitea secrets/vars without clobbering hand-provisioned values.
- RUNBOOK.md: handoff-complete ops doc.

Placeholders (<APP> <OWNER> <NS> <KEY_*>) are filled per-service on repo creation.
This commit is contained in:
Hermes Agent 2026-09-24 11:45:57 +10:00
commit 883e264c42
14 changed files with 627 additions and 0 deletions

View File

@ -0,0 +1,105 @@
# Gitea Actions workflow for a single-service homelab app.
#
# THIS IS A TEMPLATE. On repo creation from this template, Gitea copies this file
# verbatim. Fill in the placeholders below (<APP>, <OWNER>, <NS>) to match your
# service. See RUNBOOK.md §2 for the one-time per-repo setup (repo secrets/vars).
#
# Proven patterns baked in (all verified live on armistace/wedding-photos):
# - buildx driver: remote -> persistent buildkit daemon (NOT docker-container,
# which can't reach Docker Hub on this runner). endpoint MUST be top-level,
# not under driver-opts (that yields "no remote endpoint provided").
# - Kubeconfig from CI secret, kubectl installed in-job.
# - Deploy does NOT delete the namespace (preserves hand-provisioned Secrets).
# - reconcile-cluster-inject.sh keeps the live Secret/ConfigMap in sync with
# repo secrets/vars without clobbering hand-provisioned values.
# - linux/amd64 only (arm64 via qemu OOMs the buildkit pod on the NUCs).
# - Do NOT use ghcr.io images in COPY --from (buildkit can't reach it); use the
# ECR base and install uv via pip.
name: build-and-deploy
on:
push:
branches: [master]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Unit tests
working-directory: backend
run: |
uv python install 3.12
uv sync --all-groups --frozen
uv run python -m pytest -q
build-deploy:
needs: test
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create Kubeconfig
run: |
mkdir -p $HOME/.kube
echo "${{ secrets.KUBEC_CONFIG_BUILDX_NEW_2 }}" > $HOME/.kube/config
- name: Set up Docker Buildx (remote -> persistent buildkit daemon)
uses: docker/setup-buildx-action@v3
with:
driver: remote
# endpoint must be TOP-LEVEL (the action passes it positionally to
# `buildx create --driver remote <endpoint>`). Under driver-opts it
# fails with "ERROR: no remote endpoint provided".
endpoint: tcp://buildkit.gitea-runner.svc:1234
- name: Login to Gitea registry
uses: docker/login-action@v3
with:
registry: git.aridgwayweb.com
username: <OWNER>
password: ${{ secrets.REG_PASSWORD }}
- name: Build & push image
uses: docker/build-push-action@v5
with:
context: ./backend
push: true
platforms: linux/amd64
tags: |
git.aridgwayweb.com/<OWNER>/<APP>:latest
git.aridgwayweb.com/<OWNER>/<APP>:${{ github.sha }}
- name: Deploy
run: |
echo "Installing Kubectl"
apt-get update
apt-get install -y apt-transport-https ca-certificates curl gnupg
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.33/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
chmod 644 /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.33/deb/ /' | tee /etc/apt/sources.list.d/kubernetes.list
chmod 644 /etc/apt/sources.list.d/kubernetes.list
apt-get update
apt-get install -y kubectl
# Do NOT delete the namespace — that destroys the live Secret (DB creds,
# JWT, etc.) which is provisioned by hand (see RUNBOOK.md).
kubectl apply -f kube/<NS>_namespace.yaml
kubectl create secret docker-registry regcred --docker-server=git.aridgwayweb.com --docker-username=<OWNER> --docker-password='${{ secrets.REG_PASSWORD }}' --docker-email=<OWNER>@aridgwayweb.com --namespace=<NS> --dry-run=client -o yaml | kubectl apply -f -
# Apply everything EXCEPT the placeholder Secret (which would clobber the live one).
kubectl apply -f kube/<NS>_configmap.yaml
kubectl apply -f kube/<APP>-backend_deploy.yaml
kubectl apply -f kube/<APP>-backend_service.yaml
# Reconcile the live Secret + ConfigMap from Gitea repo secrets/vars so
# every env key the deployments reference exists (prevents
# CreateContainerConfigError). See scripts/reconcile-cluster-inject.sh.
export <KEY_1>="${{ secrets.<KEY_1> }}"
export <KEY_2>="${{ secrets.<KEY_2> }}"
bash scripts/reconcile-cluster-inject.sh <NS>-secret <NS>-config <NS>
kubectl rollout status deployment/<APP>-backend -n <NS> --timeout=180s

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Local runtime artifacts — never commit
__pycache__/
*.pyc
.venv/
node_modules/
dist/
*.env
.env

29
README.md Normal file
View File

@ -0,0 +1,29 @@
# Gitea Repo Template
Reusable cookie-cutter for new homelab repos on `git.aridgwayweb.com`. Creating a
repo from this template pre-installs the guards + CI patterns that took several
iterations to converge on (see `armistace/wedding-photos`), so every future repo
gets them for free.
## What you get for free
- **Hard commit guard** — local pre-commit hook (blocks direct commits to
`master`/`main`, detach-HEAD) configured via `git config core.hooksPath
~/dev/git-hooks` + Gitea branch protection on `master` (push whitelist
`[armistace]`, merge whitelist `[hermes, armistace]`).
- **Gitea Actions CI** (`.gitea/workflows/build_push.yml`) — test + build-deploy:
persistent remote buildkit cache, Gitea registry push, idempotent deploy that
preserves hand-provisioned Secrets, and cluster injection from repo
secrets/vars via `scripts/reconcile-cluster-inject.sh`.
- **Persistent buildkit cache** (`ci/buildkit/`) — single-replica Longhorn
backing, disk-pressure watchdog referenced from the control host.
- **RUNBOOK.md** — handoff-complete ops doc.
## Create a repo from this template
Gitea UI: `+` → *New Repository* → **"Copy to new repository"** → source
`hermes/gitea-repo-template`.
Automated: `~/.hermes/scripts/gitea-bootstrap-new-repo.sh` (see that script).
Then fill the `<PLACEHOLDERS>` and follow RUNBOOK §1-2. **Ops/watchdog scripts do
NOT belong in repos — keep them in `~/.hermes/scripts/`.**

140
RUNBOOK.md Normal file
View File

@ -0,0 +1,140 @@
# Gitea Repo Template — Operations Runbook
This is the **cookie-cutter** for new homelab repos. On Gitea you create a new
repo **from this template** and it copies `.gitea/`, `kube/`, `ci/buildkit/`,
`scripts/`, and this RUNBOOK. Fill in the `<PLACEHOLDERS>` per service, then
follow the bootstrap flow below. Everything documented here is proven live on
`armistace/wedding-photos`.
---
## 1. Create a repo from this template
- Gitea UI: `+` → *New Repository* → **"Copy to new repository"** source = `hermes/gitea-repo-template`.
- Bot/automation: `POST /api/v1/repos/{owner}/{repo}/generate` (See `~/.hermes/scripts/gitea-bootstrap-new-repo.sh`).
After creation, commit+push a branch filling in:
- `.gitea/workflows/build_push.yml` → `<APP>`, `<OWNER>`, `<NS>`, `<KEY_*>`
- `scripts/reconcile-cluster-inject.sh` → same placeholders
- `kube/*` , `ci/buildkit/*` → your image/service names
- This RUNBOOK's placeholders → actual namespace/app
---
## 2. One-time per-repo setup (do this once, after the first commit)
### 2.1 Repo secrets + vars (Gitea Settings → Actions/secrets)
Set on the repo (or reuse the global `armistace`-scoped ones):
- `KUBEC_CONFIG_BUILDX_NEW_2` — kubeconfig for the cluster (buildx secret)
- `REG_PASSWORD` — Gitea registry password for `<OWNER>`
- every `<KEY_*>` referenced by `reconcile-cluster-inject.sh` SECRET_KEYS
- vars for CFG: e.g. `DB_HOST`, `S3_ENDPOINT_URL`, etc.
### 2.2 Branch protection (hard guard, server-side)
Created automatically by the bootstrap script (agent runs it with the bot token
on `hermes/`-owned repos). Rule on `master`:
- `enable_push + enable_push_whitelist=true`, push whitelist `[armistace]`
→ only Andrew can DIRECT-push master; agent direct push is blocked even with `--no-verify`
- `enable_merge_whitelist=true`, merge whitelist `[hermes, armistace]`
→ agent can still MERGE PRs (merge ≠ direct push)
### 2.3 Local pre-commit guard
Point the repo at the shared git hook so it blocks direct-to-master commits:
```bash
git config core.hooksPath ~/dev/git-hooks
```
(Shared hook lives at `~/dev/git-hooks/pre-commit` on the control host; the
master-guard binary is bundled in the `gitea-pr-workflow` skill.)
---
## 3. Architecture & storage
### 3.1 Cluster injection via Gitea repo secrets + vars
`scripts/reconcile-cluster-inject.sh` reconciles the live Secret/ConfigMap each
deploy with three rules (priority order):
1. Gitea supplies a **non-empty** value → that value wins (seed / rotate).
2. Key **missing** from the live resource → write placeholder (env ref always resolves).
3. Otherwise → **preserve** the live value (hand-provisioned creds never clobbered).
The patch is built by Python `json.dumps` (no shell interpolation). Add every
key the deployments reference to `SECRET_KEYS` / `CM_KEYS`; list new repo
secrets/vars here so a handoff never loses them.
### 3.2 Persistent buildkit cache (optional, durable CI cache)
`ci/buildkit/` installs a single-replica Longhorn-backed buildkit daemon so CI
image builds reuse cache. Manifests are shipped but NOT auto-applied — apply
once:
```bash
kubectl apply -f ci/buildkit/01-storageclass.yaml
kubectl apply -f ci/buildkit/02-statefulset.yaml
kubectl apply -f ci/buildkit/03-service.yaml
```
The remote-driver workflow points at `buildkit.gitea-runner.svc:1234`.
Disk watchdog + revert scripts live on the control host
(`~/.hermes/scripts/buildkit-cache-monitor.sh`, `buildkit-re-enable.sh`) — NOT
in this repo (ops tooling doesn't go in app repos).
---
## 4. Deploying / re-deploying from scratch
### 4.1 Provision the namespace + live Secret ONCE by hand
CI will `kubectl apply` manifests and reconcile the Secret, but the FIRST seed
of real credentials is manual (CI only writes placeholders when a key is absent):
```bash
kubectl create namespace <NS>
kubectl create secret docker-registry regcred \
--docker-server=git.aridgwayweb.com --docker-username=<OWNER> \
--docker-password='<REG_PASSWORD>' --docker-email=<OWNER>@aridgwayweb.com \
-n <NS> --dry-run=client -o yaml | kubectl apply -f -
kubectl -n <NS> create secret generic <NS>-secret \
--from-literal=<KEY_1>='<real-value>' \
--from-literal=<KEY_2>='<real-value>'
```
### 4.2 Set the ConfigMap
```bash
kubectl -n <NS> apply -f kube/<NS>_configmap.yaml
```
### 4.3 After CI deploy — verify
```bash
kubectl -n <NS> get pods
kubectl -n <NS> rollout status deployment/<APP>-backend --timeout=180s
```
---
## 5. Common operations
- **Rotate a secret**: update the Gitea repo secret → push an empty commit or
re-run `workflow_dispatch` → reconcile applies the new value → `kubectl -n <NS> rollout restart deployment/<APP>-backend`.
- **If pods CrashLoopBackOff after a secret change**: the env ref is likely
unresolvable — check `kubectl get event` / secret keys; add the missing key to
`reconcile-cluster-inject.sh` SECRET_KEYS and the workflow export block.
- **Never delete the namespace** on deploy (CI does not; don't start) — it
destroys the live Secret.
---
## 6. Troubleshooting
- **Build-deploy fails ~36s, "no remote endpoint provided"**: the buildx
`endpoint` was put under `driver-opts`. Move it to a top-level `with:` input.
- **Multi-arch build OOMs/crashes the buildkit pod**: build `linux/amd64` only.
- **`COPY --from=ghcr.io/...` TLS timeout**: buildkit can't reach ghcr.io. Use
an ECR base + `pip install uv`.
- **PR files API returns empty `patch` fields**: build review payload from local
`git diff` — never trust the API `patch`.
---
## 7. Handoff checklist
- [ ] All `<PLACEHOLDERS>` filled (workflow, reconcile script, kube/, this RUNBOOK)
- [ ] Repo secrets + vars set; keys match `reconcile-cluster-inject.sh` + workflow `export` block
- [ ] Branch protection on `master` enabled (push whitelist `[armistace]`, merge whitelist `[hermes, armistace]`)
- [ ] `git config core.hooksPath ~/dev/git-hooks` set in the new repo
- [ ] First deploy: namespace seeded with a real Secret (RUNBOOK §4.1) before relying on CI reconcile
- [ ] Any new env key added to §3.1 table + §2.1 list so a handoff doesn't lose it

View File

@ -0,0 +1,27 @@
# StorageClass for the persistent buildkit cache.
# - numberOfReplicas: 1 -> the cache is written ONCE across the cluster (1x disk,
# NOT the 3x triple-replication of the default 'longhorn' SC). This is deliberate:
# a buildkit cache is throwable/recreatable, so replicating it 3x wastes disk that
# node 2 (already 76%) can't afford. 1 replica survives single-node loss via
# dataLocality: best-effort (re-replicates only when a node actually dies).
# - dataLocality: best-effort -> keep the single replica on the same node as the
# pod (fast local reads), replicate only if that node fails.
# Auto-revert safety net: the buildkit-cache-monitor cron reverts buildkit to the
# ephemeral (no-PVC) driver if any node crosses the critical disk threshold.
# See monitoring/buildkit-cache-monitor.sh.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: buildkit-single-1r
annotations:
description: "Single-replica Longhorn (buildkit cache) - 1x disk, durable across node loss"
provisioner: driver.longhorn.io
allowVolumeExpansion: true
reclaimPolicy: Delete
volumeBindingMode: Immediate
parameters:
numberOfReplicas: "1"
staleReplicaTimeout: "30"
fsType: "ext4"
dataLocality: "best-effort"
dataEngine: "v1"

View File

@ -0,0 +1,77 @@
# Dedicated long-lived buildkit daemon for CI image builds.
#
# Persists its build cache on a single-replica Longhorn volume (StorageClass
# buildkit-single-1r) so repeated CI image builds reuse the layer cache instead of
# re-pulling/building every time. The cache stays ONE copy on disk (not the 3x
# default replication) and is bounded by the 10Gi PVC. The workflow's buildx uses
# the REMOTE driver to point at this daemon via the ClusterIP Service below.
#
# Disk-pressure safety net: the buildkit-cache-monitor cron (monitoring/) reverts
# CI to the ephemeral buildx driver if any node crosses critical disk usage.
#
# NOTE: no toleration for the gitea-builder taint on archlinux-k3s-1, so this pod
# schedules on a worker node (2 or 3) away from the high-CPU control plane.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: buildkit
namespace: gitea-runner
spec:
serviceName: buildkit
replicas: 1
selector:
matchLabels:
app: buildkit
template:
metadata:
labels:
app: buildkit
spec:
containers:
- name: buildkitd
image: moby/buildkit:buildx-stable-1
args:
- --addr
# Listen for the remote driver over TCP on 1234 (no TLS - internal cluster traffic).
- tcp://0.0.0.0:1234
# Keep the cache (do NOT use --oci-worker-no-process-sandbox or other
# flags that break rootless cache persistence).
ports:
- name: daemon
containerPort: 1234
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "2"
memory: 4Gi
ephemeral-storage: 2Gi
volumeMounts:
- name: cache
mountPath: /var/lib/buildkit
# BuildKit needs this for the CA/root store even when not using TLS.
- name: certs
mountPath: /etc/buildkit/certs
- name: config
mountPath: /etc/buildkit
securityContext:
# BuildKit's OCI/runc worker must bind-mount build contexts & layers.
# The old driver-spawned (working) buildkit pods ran privileged:true;
# false here caused "failed to mount snapshot ... operation not
# permitted" (runc-native can't mount). Match the proven-working pods.
privileged: true
volumes:
- name: certs
emptyDir: {}
- name: config
emptyDir: {}
volumeClaimTemplates:
- metadata:
name: cache
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: buildkit-single-1r
resources:
requests:
storage: 10Gi

View File

@ -0,0 +1,16 @@
# ClusterIP service exposing the buildkit daemon to the CI runners.
# The workflow's buildx remote driver connects to: tcp://buildkit.gitea-runner.svc:1234
apiVersion: v1
kind: Service
metadata:
name: buildkit
namespace: gitea-runner
spec:
selector:
app: buildkit
ports:
- name: daemon
port: 1234
targetPort: 1234
protocol: TCP
type: ClusterIP

View File

@ -0,0 +1,26 @@
# NetworkPolicy: only the Gitea action runners may reach the buildkit daemon.
# Closes the reviewer's High: buildkit listens on plaintext tcp:1234 with no
# auth — without this policy ANY cluster pod could submit arbitrary build
# requests (lateral-movement / resource-abuse surface). Restricting ingress to
# the gitea-runner namespace (where the runners live) removes that exposure.
# Homelab note: full TLS+auth on the buildkit socket is deferred (documented) —
# the runner and daemon are on the private cluster network; the policy closes the
# pod-to-pod surface that TLS alone wouldn't.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: buildkit-allow-runner-only
namespace: gitea-runner
spec:
podSelector:
matchLabels:
app: buildkit
policyTypes: ["Ingress"]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: gitea-runner
ports:
- protocol: TCP
port: 1234

44
ci/buildkit/README.md Normal file
View File

@ -0,0 +1,44 @@
# Persistent Buildkit Cache for CI
Dedicated, long-lived buildkit daemon that persists its layer cache on a
single-replica Longhorn PVC so repeated CI image builds reuse cache instead of
re-building every time. This manifest set is **ported verbatim from
`armistace/wedding-photos`** — verified live.
## Why single-replica Longhorn (not the default SC)
The buildx **kubernetes driver's PVC option creates the volume with NO
`storageClassName`** — it falls back to the cluster default SC, which here is
`longhorn` at **3 replicas**. That would triple every cache byte across
disk-constrained nodes. So this uses a dedicated `buildkit-single-1r` SC
(`numberOfReplicas: 1`, `dataLocality: best-effort`): the cache is **1× on
disk** and only replicates if the hosting node actually dies.
## Components
| Path | Purpose |
|---|---|
| `ci/buildkit/01-storageclass.yaml` | `buildkit-single-1r` SC (1 replica, best-effort locality) |
| `ci/buildkit/02-statefulset.yaml` | `buildkit` StatefulSet, pod schedules off the control plane, listens `tcp://0.0.0.0:1234`, 10Gi cache PVC |
| `ci/buildkit/03-service.yaml` | ClusterIP `buildkit.gitea-runner.svc:1234` |
| `ci/buildkit/04-networkpolicy.yaml` | restrict buildkit to the gitea-runner namespace only |
| `.gitea/workflows/build_push.yml` | `setup-buildx-action` → `driver: remote`, `endpoint=tcp://buildkit.gitea-runner.svc:1234` |
> **Ops tooling is NOT in this repo.** The disk watchdog + revert/restore
> scripts live on the control machine (`~/.hermes/scripts/buildkit-cache-monitor.sh`,
> `buildkit-re-enable.sh`) and run from the `buildkit-disk-monitor` cron. They are
> homelab ops, not app code.
## Apply (one-time, per cluster — NOT per repo)
```bash
kubectl apply -f ci/buildkit/01-storageclass.yaml
kubectl apply -f ci/buildkit/02-statefulset.yaml
kubectl apply -f ci/buildkit/03-service.yaml
kubectl apply -f ci/buildkit/04-networkpolicy.yaml
kubectl -n gitea-runner rollout status statefulset/buildkit --timeout=180s
```
Buildkit is a **cluster-wide shared resource** — it should be installed once, not
once per repo. If your repo doesn't own the cluster, coordinate with whoever
does (it lives in the `gitea-runner` namespace and only needs applying once).

View File

@ -0,0 +1,25 @@
# Template placeholder deployment. Convention: <APP>-backend_deploy.yaml
# CRITICAL: every pod spec must reference the regcred imagePullSecret or pods
# hit ImagePullBackOff pulling from the private registry.
apiVersion: apps/v1
kind: Deployment
metadata:
name: <APP>-backend
namespace: <NS>
spec:
replicas: 1
selector:
matchLabels:
app: <APP>-backend
template:
metadata:
labels:
app: <APP>-backend
spec:
imagePullSecrets:
- name: regcred
containers:
- name: backend
image: git.aridgwayweb.com/<OWNER>/<APP>:latest
ports:
- containerPort: 8000

View File

@ -0,0 +1,12 @@
# Template placeholder service. Convention: <APP>-backend_service.yaml
apiVersion: v1
kind: Service
metadata:
name: <APP>-backend
namespace: <NS>
spec:
selector:
app: <APP>-backend
ports:
- port: 8000
targetPort: 8000

9
kube/NS_configmap.yaml Normal file
View File

@ -0,0 +1,9 @@
# Template placeholder ConfigMap. Add keys matching reconcile-cluster-inject.sh
# CM_KEYS entries. Convention: <NS>_configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: <NS>-config
namespace: <NS>
data:
<CONFIG_KEY_1>: ""

6
kube/NS_namespace.yaml Normal file
View File

@ -0,0 +1,6 @@
# Template placeholder file — replace with your app's namespace manifest.
# Convention: <NS>_namespace.yaml (matches the workflow's apply step).
apiVersion: v1
kind: Namespace
metadata:
name: <NS>

View File

@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Reconciles the live <NS> Secret and ConfigMap from Gitea repo secrets/vars,
# WITHOUT ever clobbering hand-provisioned real credentials.
#
# Design rationale (post CreateContainerConfigError lesson):
# The deployments reference DB creds / JWT / etc. unconditionally. If any key
# is missing from the live Secret, the pod fails to start (rollout timeout in
# CI). The Secret has historically been provisioned by hand (RUNBOOK §4.2);
# git holds placeholders only.
#
# This script makes CI the reconciler. For each managed key it applies EXACTLY
# one rule (priority order):
# 1. Gitea supplies a NON-EMPTY value -> that value wins (seed / rotate).
# 2. The key MISSING from the live resource -> write the placeholder so the
# app's env refs always resolve and the rollout never breaks.
# 3. Otherwise (key present, Gitea empty or absent) -> PRESERVE the live
# value untouched. A hand-provisioned credential is never overwritten by
# an empty Gitea secret.
#
# The patch payload is built by Python's json module (no shell interpolation),
# so arbitrary values — quotes, newlines, unicode — cannot corrupt the JSON or
# inject flags into kubectl.
set -euo pipefail
SECRET="${1:?secret name}"
CM="${2:?configmap name}"
NS="${3:?namespace}"
KUBECTL="${KUBECTL:-kubectl}"
# key -> Gitea-var-env:placeholder (placeholder applies to the Secret)
# EDIT ME: add every Secret key your deployments reference.
declare -A SECRET_KEYS=(
[<KEY_1>]=<KEY_1>:placeholder<KEY_1>
[<KEY_2>]=<KEY_2>:""
)
# key -> Gitea-var-env:placeholder (placeholders apply to the ConfigMap)
# EDIT ME: add every ConfigMap key your deployments reference.
declare -A CM_KEYS=(
[<CONFIG_KEY_1>]=<CONFIG_KEY_1>:""
)
live_has_key() { # kind name key -> 0 if the resource has the key
local v
v="$($KUBECTL get "$1" "$2" -n "$NS" -o "jsonpath={.data.$3}" 2>/dev/null || true)"
[[ -n "$v" ]]
}
# Build a JSON patch body {"data": {...}} safely via Python json.
# Args: kind newline-joined "key<TAB>value" lines.
build_patch() {
python3 -c '
import sys, json, base64
kind = sys.argv[1]
data = {}
for line in sys.argv[2].split("\n"):
if not line:
continue
key, _, val = line.partition("\t")
data[key] = base64.b64encode(val.encode("utf-8")).decode("ascii") if kind == "secret" else val
print(json.dumps({"data": data}, ensure_ascii=False))
' "$1" "$2"
}
# emit_patch KIND assoc-name resource-kind resource-name
emit_patch() {
local kind="$1" declare_var="$2" reskind="$3" resname="$4"
local -n MAP="$declare_var"
local lines=() key envvar placeholder val
for key in "${!MAP[@]}"; do
envvar="${MAP[$key]%%:*}"
placeholder="${MAP[$key]#*:}"
val="${!envvar:-}"
if [[ -n "$val" ]]; then
lines+=("$key"$'\t'"$val")
elif ! live_has_key "$reskind" "$resname" "$key"; then
lines+=("$key"$'\t'"$placeholder")
fi
done
if [[ ${#lines[@]} -gt 0 ]]; then
build_patch "$kind" "$(printf '%s\n' "${lines[@]}")"
else
echo '{}'
fi
}
apply_secret() {
local body
body="$(emit_patch secret SECRET_KEYS secret "$SECRET")"
[[ "$body" == '{}' ]] || $KUBECTL patch secret "$SECRET" -n "$NS" --type merge -p "$body" >/dev/null
echo "reconciled secret $SECRET ($NS): non-empty Gitea applied, missing seeded, present preserved"
}
apply_cm() {
local body
body="$(emit_patch configmap CM_KEYS configmap "$CM")"
[[ "$body" == '{}' ]] || $KUBECTL patch configmap "$CM" -n "$NS" --type merge -p "$body" >/dev/null
echo "reconciled configmap $CM ($NS): non-empty Gitea applied, missing seeded, present preserved"
}
apply_secret
apply_cm