From d7c30a8af663829f041cdede5a089ad9e5ac4ea9 Mon Sep 17 00:00:00 2001 From: Story Crater Bot <19826264+Riotpiaole@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:10:06 -0700 Subject: [PATCH] =?UTF-8?q?chore:=20retire=20TemporalWorker=20CRD=20?= =?UTF-8?q?=E2=80=94=20agent-harness-worker=20and=20Forgejo=20build=20work?= =?UTF-8?q?flow=20removed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messaging/queue-crd/templates/rbac.yaml | 12 - .../templates/temporalworker-crd.yaml | 62 ---- k8s_authentik-migration.md | 270 ++++++++++++++++++ scripts/device-flow-demo.sh | 116 ++++++++ scripts/pi-stage1-prompt.md | 72 +++++ 5 files changed, 458 insertions(+), 74 deletions(-) delete mode 100644 k8s/apps/messaging/queue-crd/templates/temporalworker-crd.yaml create mode 100644 k8s_authentik-migration.md create mode 100755 scripts/device-flow-demo.sh create mode 100644 scripts/pi-stage1-prompt.md diff --git a/k8s/apps/messaging/queue-crd/templates/rbac.yaml b/k8s/apps/messaging/queue-crd/templates/rbac.yaml index 20da39b..39a46d7 100644 --- a/k8s/apps/messaging/queue-crd/templates/rbac.yaml +++ b/k8s/apps/messaging/queue-crd/templates/rbac.yaml @@ -18,15 +18,6 @@ rules: - apiGroups: ["kmsvc.io"] resources: ["queues/finalizers"] verbs: ["update"] - - apiGroups: ["kmsvc.io"] - resources: ["temporalworkers"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: ["kmsvc.io"] - resources: ["temporalworkers/status"] - verbs: ["get", "update", "patch"] - - apiGroups: ["kmsvc.io"] - resources: ["temporalworkers/finalizers"] - verbs: ["update"] - apiGroups: ["coordination.k8s.io"] resources: ["leases"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] @@ -36,9 +27,6 @@ rules: - apiGroups: [""] resources: ["pods", "nodes"] verbs: ["get"] - - apiGroups: ["apps"] - resources: ["deployments"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/k8s/apps/messaging/queue-crd/templates/temporalworker-crd.yaml b/k8s/apps/messaging/queue-crd/templates/temporalworker-crd.yaml deleted file mode 100644 index 54e0e45..0000000 --- a/k8s/apps/messaging/queue-crd/templates/temporalworker-crd.yaml +++ /dev/null @@ -1,62 +0,0 @@ -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - name: temporalworkers.kmsvc.io -spec: - group: kmsvc.io - names: - kind: TemporalWorker - plural: temporalworkers - singular: temporalworker - scope: Namespaced - versions: - - name: v1 - served: true - storage: true - schema: - openAPIV3Schema: - type: object - required: - - spec - properties: - apiVersion: - type: string - kind: - type: string - metadata: - type: object - spec: - type: object - description: Temporal worker specification - properties: - namespace: - type: string - description: Temporal namespace - taskQueue: - type: string - description: Task queue name - workflowTypes: - type: array - items: - type: string - description: List of workflow types to execute - activityTypes: - type: array - items: - type: string - description: List of activity types to execute - concurrency: - type: integer - minimum: 1 - description: Worker concurrency level - status: - type: object - description: Temporal worker status - properties: - ready: - type: boolean - lastHeartbeat: - type: string - format: date-time - error: - type: string diff --git a/k8s_authentik-migration.md b/k8s_authentik-migration.md new file mode 100644 index 0000000..5702e65 --- /dev/null +++ b/k8s_authentik-migration.md @@ -0,0 +1,270 @@ +# Kubernetes RBAC → Authentik Migration Plan + +**Status:** Draft — awaiting decisions in [Open Decisions](#open-decisions) +**Date:** 2026-08-19 +**Scope:** Move human identity + authorization for the cluster and its apps onto Authentik groups. + +--- + +## 1. Finding: there is nothing to literally import + +The original ask was "import the existing RBAC from the cluster into Authentik". Inventory of the live +cluster says that set is empty. + +### 1.1 Cluster RBAC audit (live, 2026-08-19) + +Every `ClusterRoleBinding` / `RoleBinding` carrying a `User` or `Group` subject: + +| Binding | Role | Subjects | +|---|---|---| +| `cluster-admin` | `ClusterRole/cluster-admin` | `Group:system:masters` | +| `system-bootstrap-approve-node-client-csr` | `ClusterRole/system:certificates.k8s.io:certificatesigningrequests:nodeclient` | `Group:system:bootstrappers:nodes` | +| `system-bootstrap-node-bootstrapper` | `ClusterRole/system:node-bootstrapper` | `Group:system:bootstrappers:nodes`, `Group:system:nodes` | +| `system-bootstrap-node-renewal` | `ClusterRole/system:certificates.k8s.io:certificatesigningrequests:selfnodeclient` | `Group:system:nodes` | +| `kube-system/system::extension-apiserver-authentication-reader` | `Role/extension-apiserver-authentication-reader` | `User:system:kube-controller-manager`, `User:system:kube-scheduler` | +| `kube-system/system::leader-locking-kube-controller-manager` | `Role/…` | `User:system:kube-controller-manager` | +| `kube-system/system::leader-locking-kube-scheduler` | `Role/…` | `User:system:kube-scheduler` | + +All of these are Talos/control-plane machinery. Every other binding in the cluster targets a +`ServiceAccount` (controllers: Argo CD, cert-manager, Cilium, Longhorn, MinIO operator, Prometheus, …). + +**There are zero human users in Kubernetes RBAC.** The only human access path today is the Talos-issued +admin certificate in `cluster-config/kubeconfig`, which lands in `system:masters` — a single shared, +unattributable, non-revocable god credential. + +The kube-apiserver has no OIDC configured. `apiServer:` in +[`terraform/templates/controlplane.tftpl:110`](terraform/templates/controlplane.tftpl#L110) contains only +`certSANs`, `image`, `admissionControl` and `auditPolicy`. + +### 1.2 What human RBAC *does* exist (app level, scattered) + +| Location | Rule | +|---|---| +| [`k8s/bootstrap/phase4-argocd/argocd-values.yaml:163-169`](k8s/bootstrap/phase4-argocd/argocd-values.yaml#L163-L169) | `policy.default: role:readonly`; `g, admin/rock/cicd, role:admin`; `g, homelab-admins, role:admin` | +| [`k8s/infra/logging/grafana-values.yaml:86`](k8s/infra/logging/grafana-values.yaml#L86) | `role_attribute_path: contains(groups[*], 'grafana-admins') && 'Admin' \|\| 'Viewer'` | +| [`k8s/infra/iam/scripts/authentik-provision.py`](k8s/infra/iam/scripts/authentik-provision.py) (`minio` scope mapping) | `homelab-admins → consoleAdmin`, everyone else → `readonly` | +| Authentik, provisioned | Groups `homelab-admins` (superuser), `grafana-admins`. Single user: `rock`, member of both. | + +### 1.3 Known drift + +[`project-usage/authentik-oidc.md`](project-usage/authentik-oidc.md) documents three groups — +`homelab-admins`, `homelab-devops`, `homelab-viewers`. Only `homelab-admins` and `grafana-admins` are +actually created by the provisioner. The documentation is wrong today; Phase 1 fixes it. + +### 1.4 Restated goal + +Not an import. This is: **define the group taxonomy in Authentik, make Authentik the sole issuer of human +identity, then bind those groups to Kubernetes roles and app roles declaratively through GitOps.** + +--- + +## 2. Constraints + +- **GitOps only.** No local `terraform apply`, no manual `kubectl apply`, no Authentik console clicking for + anything that must survive a rebuild. Changes land in git; Forgejo Actions and Argo CD do the applying. +- **Authentik config stays Argo CD-managed** via the `authentik-provision.py` PostSync hook. No Terraform + Authentik provider. A prior attempt at one is abandoned at + `.claude/worktrees/debug-auth-issue/terraform/authentik-config.tf` — do not revive it. +- **Terraform is limited to Talos machine config** (Phase 3 only), applied through the pipeline. +- **The Talos admin certificate stays as break-glass.** It is unaffected by OIDC and remains the recovery + path when Authentik is unavailable. + +--- + +## 3. Phases + +Each phase is independently shippable and independently revertible. + +### Phase 1 — Group taxonomy + +Extend the provisioner's group step (`[2/5]` in `authentik-provision.py`) from two groups to four: + +| Group | `is_superuser` | Intent | +|---|---|---| +| `homelab-admins` | `true` | Full cluster + full app admin | +| `homelab-devops` | `false` | Deploy and operate workloads; no IAM, no cluster config | +| `homelab-viewers` | `false` | Read-only everywhere | +| `grafana-admins` | `false` | Grafana `Admin` role specifically | + +Files: `k8s/infra/iam/scripts/authentik-provision.py`, `project-usage/authentik-oidc.md` (fix §1.3 drift). + +**Verify** +```bash +kubectl -n iam exec deploy/authentik-server -- \ + curl -sH "Authorization: Bearer $TOKEN" localhost:9000/api/v3/core/groups/ \ + | jq -r '.results[].name' +# expect: homelab-admins, homelab-devops, homelab-viewers, grafana-admins +# and: rock still a member of homelab-admins + grafana-admins +``` + +**Rollback:** revert the commit. Extra groups with no bindings grant nothing. + +--- + +### Phase 2 — Authentik OAuth2 provider for Kubernetes + +Add a `kubernetes` entry to the `SERVICES` dict in `authentik-provision.py`, with two departures from the +existing web-app entries: + +- `client_type: public` — kubectl is a public client and cannot hold a secret. No k8s Secret is created for + it, so the `client_secret_source` machinery is skipped for this service. +- `grant_types: ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"]` + plus PKCE, to support both browser and headless login. +- `redirect_uris`: `http://localhost:8000`, `http://localhost:18000` (kubelogin's loopback listeners). + +Reuses the existing `groups` property mapping created in step `[1/5]`. No new claim work. + +**Verify** +```bash +curl -s https://authentik.riotpiao.com/application/o/kubernetes/.well-known/openid-configuration | jq . + +# device-code login, then decode: +kubectl oidc-login get-token --oidc-issuer-url=... --oidc-client-id=kubernetes --oidc-extra-scope=groups +# id_token payload must contain: preferred_username, groups[] +``` + +**Rollback:** delete provider + application in Authentik; nothing else consumes them yet. + +--- + +### Phase 3 — Enable OIDC on kube-apiserver (Terraform, pipeline-applied) + +Add to `apiServer.extraArgs` in +[`terraform/templates/controlplane.tftpl`](terraform/templates/controlplane.tftpl#L110): + +```yaml + extraArgs: + oidc-issuer-url: https://authentik.riotpiao.com/application/o/kubernetes/ + oidc-client-id: kubernetes + oidc-username-claim: preferred_username + oidc-username-prefix: "authentik:" + oidc-groups-claim: groups + oidc-groups-prefix: "authentik:" +``` + +> **Security — the prefixes are mandatory, not cosmetic.** +> Without `oidc-groups-prefix`, anyone able to create or rename an Authentik group to `system:masters` +> obtains `cluster-admin` on the cluster. Without `oidc-username-prefix`, an Authentik username can +> impersonate a built-in identity such as `system:kube-controller-manager`. Both prefixes must be present +> in the same change that enables OIDC. Do not ship this phase partially. + +Operational notes: + +- Apply is a rolling control-plane machine-config change: one node at a time, wait for `Ready` and for + etcd quorum before proceeding to the next. +- The Talos admin certificate authenticates via client cert, not OIDC, and is unaffected. It is the + recovery path if the issuer URL is wrong or Authentik is down. +- The issuer must be reachable from the control-plane nodes and its TLS chain must be trusted by them. + Confirm this before applying, because a bad issuer URL means the apiserver logs OIDC discovery failures + on every start. + +**Verify** (per node, after each rolls) +```bash +kubectl auth whoami --token="$ID_TOKEN" +# expect Username: authentik:rock +# Groups: authentik:homelab-admins, authentik:grafana-admins, system:authenticated + +kubectl --kubeconfig cluster-config/kubeconfig get nodes # cert path still works +``` + +**Rollback:** revert the template change, re-run the pipeline, roll the control plane back. Cert-based +access is never interrupted, so this rollback is safe at any point. + +--- + +### Phase 4 — Bind Authentik groups to Kubernetes roles + +New manifest `k8s/infra/iam/rbac-oidc-bindings.yaml`, referenced from +[`k8s/infra/iam/kustomization.yaml`](k8s/infra/iam/kustomization.yaml): + +| Subject | Role | Kind | +|---|---|---| +| `Group:authentik:homelab-admins` | `ClusterRole/cluster-admin` | ClusterRoleBinding | +| `Group:authentik:homelab-devops` | `ClusterRole/edit` | see [Open Decisions](#open-decisions) #2 | +| `Group:authentik:homelab-viewers` | `ClusterRole/view` | ClusterRoleBinding | + +`admin`, `edit` and `view` are the built-in aggregated ClusterRoles and already exist in the cluster — no +custom roles needed. + +**Verify** +```bash +kubectl auth can-i '*' '*' --all-namespaces --as=probe --as-group=authentik:homelab-admins # yes +kubectl auth can-i delete pods -A --as=probe --as-group=authentik:homelab-viewers # no +kubectl auth can-i get pods -n apps --as=probe --as-group=authentik:homelab-viewers # yes +kubectl auth can-i get secrets -n iam --as=probe --as-group=authentik:homelab-devops # no +``` + +**Rollback:** revert the commit; Argo CD prunes the bindings. + +--- + +### Phase 5 — Client wiring and documentation + +- Ship a kubeconfig template using the `kubelogin` (`kubectl oidc-login`) exec credential plugin — no + embedded certs, no long-lived token on disk. +- Document login, token cache location, and the break-glass cert path in + [`project-usage/authentik-oidc.md`](project-usage/authentik-oidc.md). +- Same edit corrects the stale group list from §1.3. + +**Verify:** on a clean machine with no certificates, `kubectl get nodes` triggers a browser login and +succeeds; `kubectl auth whoami` reports the `authentik:`-prefixed identity. + +--- + +### Phase 6 — Converge app RBAC on the same four groups + +| Target | Change | +|---|---| +| Argo CD `policy.csv` | add `g, homelab-devops, role:admin` (or a scoped custom role) and `g, homelab-viewers, role:readonly` | +| Grafana `role_attribute_path` | three-tier: `grafana-admins`/`homelab-admins` → `Admin`, `homelab-devops` → `Editor`, else `Viewer` | +| MinIO `policy` claim expression | `homelab-admins` → `consoleAdmin`, `homelab-devops` → `readwrite`, else `readonly` | +| Argo CD local accounts | retire `accounts.rock` once SSO admin is proven. **Keep `accounts.cicd`** — the pipeline needs a non-SSO apiKey. | + +**Verify:** end-to-end browser login per app as a test user who is *only* in `homelab-devops`, confirming +the expected role in each of Argo CD, Grafana and MinIO. The e2e suite under +[`tests/e2e/tests/authentik.spec.ts`](tests/e2e/tests/authentik.spec.ts) is the place to encode this. + +**Rollback:** per-app revert; each app's mapping is independent. + +--- + +## 4. Risks + +| Risk | Severity | Mitigation | +|---|---|---| +| Missing `oidc-groups-prefix` → group-name privilege escalation to `cluster-admin` | Critical | Prefixes ship in the same commit as the OIDC flags; Phase 4 verification asserts the prefixed form | +| Bad issuer URL / untrusted TLS bricks apiserver OIDC | Medium | Cert-based admin access is unaffected; roll one control-plane node at a time and check apiserver logs before continuing | +| Authentik outage blocks all human cluster access | Medium | Talos admin cert is the documented break-glass path and never depends on Authentik | +| Argo CD `cicd` account removed by accident → pipeline loses cluster access | Medium | Phase 6 explicitly retires only `accounts.rock` | +| Group renamed in Authentik silently revokes cluster access | Low | Group names are asserted by the provisioner on every Argo CD sync, so drift self-heals | + +--- + +## 5. Open Decisions + +1. **Is kubectl-via-OIDC actually wanted?** + Phases 3–5 only pay for themselves if humans besides the shared admin cert need API access. If the + answer is no, the plan collapses to Phases 1 and 6: no Terraform change, no control-plane roll, app-level + SSO consolidation only. + +2. **`homelab-devops` blast radius** — cluster-wide `ClusterRoleBinding` to `edit`, or namespaced + `RoleBinding`s limited to application namespaces (excluding `iam`, `kube-system`, `argocd`)? + Namespaced is the tighter default; cluster-wide is less to maintain. + +3. **Username claim** — `preferred_username` (human-readable in audit logs, mutable) versus `sub` + (stable, opaque). Recommendation: `preferred_username`, given a single-operator homelab where audit + readability beats rename-safety. + +--- + +## 6. Reference + +- Kubernetes OIDC authentication: https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens +- Kubernetes RBAC: https://kubernetes.io/docs/reference/access-authn-authz/rbac/ +- Talos apiserver `extraArgs`: https://www.talos.dev/latest/reference/configuration/v1alpha1/config/#Config.cluster.apiServer +- Authentik OAuth2 provider: https://docs.goauthentik.io/docs/add-secure-apps/providers/oauth2/ +- Authentik property mappings (scope/claims): https://docs.goauthentik.io/docs/add-secure-apps/providers/property-mappings/ +- kubelogin (`kubectl oidc-login`): https://github.com/int128/kubelogin +- Argo CD RBAC: https://argo-cd.readthedocs.io/en/stable/operator-manual/rbac/ +- Grafana generic OAuth: https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-authentication/generic-oauth/ +- MinIO OpenID identity management: https://min.io/docs/minio/linux/administration/identity-access-management/oidc-access-management.html diff --git a/scripts/device-flow-demo.sh b/scripts/device-flow-demo.sh new file mode 100755 index 0000000..f8c8fba --- /dev/null +++ b/scripts/device-flow-demo.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# device-flow-demo.sh -- showcase Authentik OAuth 2.0 Device Authorization +# Grant (RFC 8628) end to end, pure curl + jq. +# +# Requires: curl, jq +# +# Usage: +# CLIENT_ID= ./scripts/device-flow-demo.sh +# +# Steps: +# 1. Requests a device_code + user_code from Authentik. +# 2. Prints the verification URL + user_code for approval in a browser. +# 3. Polls the token endpoint until approved, then prints the access_token. +# +# NOTE on quoting: the device_code returned by Authentik can contain raw +# quote, backslash, and backtick characters. Always pass it to curl via +# --data-urlencode from a shell variable (curl encodes it safely). Never +# re-embed it inside another quoted string (a Python literal, a second shell +# layer, etc) -- that is what breaks it. + +set -euo pipefail + +AUTHENTIK_URL="${AUTHENTIK_URL:-https://authentik.riotpiao.com}" +CLIENT_ID="${CLIENT_ID:?CLIENT_ID is required}" +SCOPE="${SCOPE:-openid}" + +JQ_DEVICE_CODE='.device_code' +JQ_USER_CODE='.user_code' +JQ_VERIFICATION_URI='.verification_uri' +JQ_EXPIRES_IN='.expires_in' +JQ_INTERVAL='.interval' +JQ_ERROR='.error' +JQ_ERROR_DESC='.error_description' +JQ_ACCESS_TOKEN='.access_token' + +echo "==> Requesting device code from ${AUTHENTIK_URL}..." +if ! DEVICE_RESP="$(curl -sS --fail \ + "${AUTHENTIK_URL}/application/o/device/" \ + --data-urlencode "client_id=${CLIENT_ID}" \ + --data-urlencode "scope=${SCOPE}")"; then + echo "FAILED: device code request errored (bad CLIENT_ID or unreachable Authentik)." >&2 + exit 1 +fi + +DEVICE_CODE="$(echo "$DEVICE_RESP" | jq -r "$JQ_DEVICE_CODE")" +if [ -z "$DEVICE_CODE" ] || [ "$DEVICE_CODE" = "null" ]; then + echo "FAILED: no device_code in response: $DEVICE_RESP" >&2 + exit 1 +fi + +USER_CODE="$(echo "$DEVICE_RESP" | jq -r "$JQ_USER_CODE")" +VERIFICATION_URI="$(echo "$DEVICE_RESP" | jq -r "$JQ_VERIFICATION_URI")" +EXPIRES_IN="$(echo "$DEVICE_RESP" | jq -r "$JQ_EXPIRES_IN")" +INTERVAL="$(echo "$DEVICE_RESP" | jq -r "${JQ_INTERVAL} // 5")" + +echo +echo " Go to: ${VERIFICATION_URI}" +echo " Enter code: ${USER_CODE}" +echo " (expires in ${EXPIRES_IN}s)" +echo +echo "==> Polling for approval every ${INTERVAL}s..." + +DEADLINE=$(( $(date +%s) + EXPIRES_IN )) +ACCESS_TOKEN="" + +while [ "$(date +%s)" -lt "$DEADLINE" ]; do + sleep "$INTERVAL" + + TOKEN_RESP="$(curl -sS \ + "${AUTHENTIK_URL}/application/o/token/" \ + --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:device_code" \ + --data-urlencode "device_code=${DEVICE_CODE}" \ + --data-urlencode "client_id=${CLIENT_ID}")" + + ERR="$(echo "$TOKEN_RESP" | jq -r "${JQ_ERROR} // empty")" + + if [ -z "$ERR" ]; then + ACCESS_TOKEN="$(echo "$TOKEN_RESP" | jq -r "$JQ_ACCESS_TOKEN")" + break + elif [ "$ERR" = "authorization_pending" ]; then + echo " ...still waiting for approval" + continue + elif [ "$ERR" = "slow_down" ]; then + INTERVAL=$((INTERVAL + 5)) + continue + else + DESC="$(echo "$TOKEN_RESP" | jq -r "${JQ_ERROR_DESC} // ${JQ_ERROR}")" + echo "FAILED: $DESC" >&2 + exit 1 + fi +done + +if [ -z "$ACCESS_TOKEN" ]; then + echo "Timed out waiting for approval." >&2 + exit 1 +fi + +echo +echo "==> Got a token. Decoded payload:" +python3 - "$ACCESS_TOKEN" <<'PYEOF' +import sys, json, base64 +tok = sys.argv[1].split(".")[1] +tok += "=" * (-len(tok) % 4) +print(json.dumps(json.loads(base64.urlsafe_b64decode(tok)), indent=2)) +PYEOF + +echo +echo "==> Example use as a bearer token:" +echo "curl https://api.riotpiao.com/v1/reasoning/chat/completions \\" +echo " -H \"Authorization: Bearer ${ACCESS_TOKEN}\" \\" +echo " -H \"Content-Type: application/json\" \\" +echo " -d \"{\\\"model\\\":\\\"reasoning\\\",\\\"messages\\\":[{\\\"role\\\":\\\"user\\\",\\\"content\\\":\\\"hi\\\"}]}\"" +echo +echo "NOTE: api.riotpiao.com does not yet validate Authentik JWTs -- Kongs" +echo "model routes currently enforce a static key-auth credential instead." +echo "See k8s/apps/api/DEVICE-GRANT-PLAN.md Phase 2 for the pending work." diff --git a/scripts/pi-stage1-prompt.md b/scripts/pi-stage1-prompt.md new file mode 100644 index 0000000..e83dd3d --- /dev/null +++ b/scripts/pi-stage1-prompt.md @@ -0,0 +1,72 @@ +Implement **Stage 1 only** of the approved plan at +`/Users/rockliang/.claude/plans/fluttering-cooking-thunder.md`. Read that file first — it is +the spec. Do not implement Stage 2, 3 or 4. + +## Already done, do not redo + +Stage 0 passed. The Forgejo registry returns distinct, correctly-ordered image +creation timestamps, so `newest-build` is viable: + +``` +rock/api-gateway v0.0.0 2026-08-20T05:19:48.655Z +rock/api-gateway v0.1.0 2026-08-20T06:57:11.943Z +rock/api-gateway v0.1.1 2026-08-20T07:10:13.093Z +``` + +Note the images are multi-arch OCI indexes: reading `created` means descending +index -> amd64 manifest -> config blob. + +## Scope: Stage 1 = A1, A2, A4, A6, B, C1 + +- **A1** — in `~/workplace/homelab`, replace the per-repo Forgejo entry in + `k8s/argocd/projects/homelab-project.yaml` `sourceRepos` with a wildcard + `https://forgejo.riotpiao.com/rock/*`. +- **A2** — add an Argo `Application` at sync-wave `-1` that syncs + `k8s/argocd/projects/`. Nothing owns that directory today, which is why the + AppProject only ever reaches the cluster by hand. +- **A4** — Forgejo webhook to `https://argocd.riotpiao.com/api/webhook` with a + shared secret stored in `argocd-secret` (SOPS/ksops). Register it on + `rock/homelab` and `rock/homelab-frontend`. +- **A6** — add the `forgejo-registry` dockerconfigjson pull secret for any + namespace that needs it, as a new `*.enc.yaml` listed in + `k8s/argocd/secrets/secret-generator.yaml`. It currently exists only in `api`. +- **B + C1** — in `~/workplace/homelab-frontend`: delete the dead + `.github/workflows/ci.yml`, add `.forgejo/workflows/ci.yaml` and + `.forgejo/workflows/build.yaml`, and add a multi-stage distroless `Dockerfile` + (none exists today). + +Stage 1 stops before Argo CD Image Updater. Do **not** install it and do not add +image-updater annotations — that is Stage 2. + +## Hard constraints + +- **`runs-on: docker`.** That is the runner's only registered label. The existing + `.github/workflows/ci.yml` uses `ubuntu-latest`, which is exactly why it has + never executed once. +- **No git tags, ever.** Image tag is the commit short SHA: `$(git rev-parse --short HEAD)`. + Do not use `git describe`, do not create or push tags, do not use `latest`. +- **Build workflow only builds and pushes.** No manifest write-back, no git push + from CI, no `[skip ci]` guard needed. +- **GitOps only.** No `kubectl apply`, no `helm upgrade`, no local `terraform apply`. + `kubectl --dry-run=server` and read-only `kubectl get`/`logs` are fine. +- **Never `git reset --hard`.** +- Push directly to `main`, no PRs, no branches. The cluster repo has three + remotes — `origin` is Forgejo, `github` is GitHub. Push to both; they are + currently in sync at `43483da`. +- Match surrounding file style. This repo comments the *why* on non-obvious + config, and `kustomization.yaml` uses explicit `resources:` allowlists — a file + you add and forget to list is silently dropped. + +## Verify before claiming done + +- `kubectl kustomize` each directory you touch. +- `kubectl apply --dry-run=server -f` every manifest you add or change. +- Confirm the AppProject wildcard is live: + `kubectl -n argocd get appproject homelab -o jsonpath='{.spec.sourceRepos}'` +- Push an empty commit to `homelab-frontend` and confirm the build job actually + runs on the Forgejo runner and pushes `forgejo.riotpiao.com/rock/api-gateway:`. + A workflow that does not trigger is the single most likely failure here. +- Report what you verified with real command output, not assertions. If a step + fails, say so and stop rather than working around it. + +Kubeconfig: `/Users/rockliang/workplace/homelab/cluster-config/kubeconfig`.