Commit Graph
31 Commits
Author SHA1 Message Date
Story Crater Bot be2a56ccf5 fix(iam): don't PATCH existing authentik applications — detail endpoint enforces access policy and 404s for akadmin, aborting the loop before all providers got grant_types 2026-07-22 08:09:15 -07:00
Story Crater Bot 3d8a965718 refactor(iam): extract provision python to scripts/authentik-provision.py + fix app-list idempotency — configMapGenerator (stable name) replaces inline script; superuser_full_list=true stops the 400 that aborted grant_types patching 2026-07-22 08:01:55 -07:00
Story Crater Bot 246196407a fix(iam): set OAuth2 provider grant_types + non-deprecated groups claim — empty grant_types made authentik reject authorization_code, breaking SSO login for every app 2026-07-21 23:40:49 -07:00
Story Crater Bot 1361c9bd13 fix(authentik): widen server probe timeouts (3s->15s) — slow-but-200 health checks under DB contention triggered a liveness kill loop, dropping the pod from Service endpoints and breaking OAuth provisioning 2026-07-21 22:31:48 -07:00
Story Crater Bot 261fa6faa8 fix(iam): authentik-provision Job failing on apk permission denied
Job was crash-looping: 'apk add --no-cache curl' failed with Permission
denied - the container runs as non-root UID 1000 (securityContext.
runAsNonRoot: true), and both apk's working directories and /usr/local/bin
(where curl-downloaded kubectl was being written) are root-owned in the
python:3.12-alpine base image.

Replaced with a pure-Python download via urllib (stdlib, already a
dependency of this Job) writing to /tmp (world-writable) instead - no apk
install needed at all. PATH is extended to include /tmp before invoking the
provisioning script so authentik-provision.py's existing
subprocess.run(['kubectl', ...]) calls resolve it via normal PATH lookup,
no changes needed to the script itself.
2026-07-21 16:50:22 -07:00
Story Crater Bot f08fb2bb75 fix(iam): register authentik-provision-job.yaml in kustomization + remove unsafe namespace transformer
Root cause of the provisioning Job never appearing in-cluster despite being
committed and pushed: k8s/security/iam/kustomization.yaml has an explicit
resources: allowlist (not a plain directory scan) and the new file was never
added to it, so ArgoCD's Kustomize build silently omitted every object in it
- no error, no drift shown, iam-jobs just reported Synced/Healthy against a
manifest set that never included the new Job/ConfigMap/RBAC at all.

Also removed the top-level  transformer. It would have
force-rewritten metadata.namespace to iam on every resource in this
kustomization, including authentik-provision-job.yaml's RoleBindings which
deliberately target cicd/argocd/logging/storage (least-privilege access for
the authentik-provisioner ServiceAccount to read/create Secrets in exactly
those namespaces and no others). Every manifest in this directory already
sets its own explicit namespace, so dropping the transformer is a no-op for
the existing key-rotation-cronjob.yaml.

Verified with apiVersion: v1
kind: ServiceAccount
metadata:
  name: authentik-provisioner
  namespace: iam
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: authentik-provisioner
rules:
- apiGroups:
  - ""
  resources:
  - secrets
  verbs:
  - get
  - list
  - create
  - update
  - patch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: authentik-provisioner
  namespace: argocd
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: authentik-provisioner
subjects:
- kind: ServiceAccount
  name: authentik-provisioner
  namespace: iam
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: authentik-provisioner
  namespace: cicd
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: authentik-provisioner
subjects:
- kind: ServiceAccount
  name: authentik-provisioner
  namespace: iam
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: authentik-provisioner
  namespace: iam
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: authentik-provisioner
subjects:
- kind: ServiceAccount
  name: authentik-provisioner
  namespace: iam
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: authentik-provisioner
  namespace: logging
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: authentik-provisioner
subjects:
- kind: ServiceAccount
  name: authentik-provisioner
  namespace: iam
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: authentik-provisioner
  namespace: storage
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: authentik-provisioner
subjects:
- kind: ServiceAccount
  name: authentik-provisioner
  namespace: iam
---
apiVersion: v1
data:
  authentik-provision.py: |
    #!/usr/bin/env python3
    """
    Authentik OAuth provisioning - idempotent, safe to re-run (ArgoCD PostSync hook).

    Creates/updates, in order:
      1. A custom "groups" OAuth2 scope mapping (Authentik ships openid/email/profile
         by default but NOT groups - required for ArgoCD RBAC group mapping and
         Grafana's role_attribute_path, both of which read a `groups` claim).
      2. Groups: homelab-admins (is_superuser=true), grafana-admins.
      3. User "rock": created if missing, always (re-)synced into both groups above.
         Password is generated once and only written to the k8s Secret
         rock-credentials (iam ns) the first time the user is created - re-runs
         never rotate an existing password.
      4. OAuth2/OIDC providers + Applications for: grafana, minio, forgejo, argocd.
         Client secrets are read from existing k8s Secrets (grafana-oidc, minio-oidc)
         if present, or generated once and written out (forgejo-oidc, oidc-secret)
         the first time.
      5. PolicyBinding of homelab-admins -> every Application above, so "rock" (and
         anyone else in that group) has guaranteed access regardless of each app's
         default visibility.

    Talks to Authentik over the in-cluster Service (authentik-server.iam.svc:80),
    authenticating with the bootstrap token. Everything is done with GET-then-
    create-or-patch so this can be re-run on every ArgoCD sync without duplicating
    or clobbering objects (PostSync hook, not a one-shot Job with hook-delete).

    kubectl is used only to read/write the small set of Secrets this script
    touches - it shells out rather than using the Python k8s client to keep the
    container image to stdlib Python + the kubectl binary, no pip installs.
    """
    import json
    import os
    import secrets
    import string
    import subprocess
    import sys
    import urllib.error
    import urllib.request

    AUTHENTIK_URL = "http://authentik-server.iam.svc.cluster.local"
    TOKEN = os.environ["AUTHENTIK_BOOTSTRAP_TOKEN"]

    def api(method, path, data=None):
        url = f"{AUTHENTIK_URL}{path}"
        body = json.dumps(data).encode() if data is not None else None
        req = urllib.request.Request(
            url,
            data=body,
            method=method,
            headers={
                "Authorization": f"Bearer {TOKEN}",
                "Content-Type": "application/json",
            },
        )
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                raw = resp.read()
                return resp.status, (json.loads(raw) if raw else {})
        except urllib.error.HTTPError as e:
            raw = e.read()
            try:
                parsed = json.loads(raw) if raw else {}
            except json.JSONDecodeError:
                parsed = {"raw": raw.decode(errors="replace")}
            return e.code, parsed

    def die(msg):
        print(f"FATAL: {msg}", file=sys.stderr)
        sys.exit(1)

    def gen_secret(n=40):
        alphabet = string.ascii_letters + string.digits
        return "".join(secrets.choice(alphabet) for _ in range(n))

    def kubectl_get_secret_key(namespace, name, key):
        """Returns decoded value, or None if the secret/key doesn't exist."""
        p = subprocess.run(
            ["kubectl", "-n", namespace, "get", "secret", name, "-o", f"jsonpath={{.data.{key}}}"],
            capture_output=True, text=True,
        )
        if p.returncode != 0 or not p.stdout.strip():
            return None
        import base64
        return base64.b64decode(p.stdout).decode()

    def kubectl_create_secret(namespace, name, literals: dict):
        """Idempotent: create-or-update via dry-run|apply, same pattern used
        elsewhere in this repo (setup_vault.sh, apply-vault-secrets.sh)."""
        args = ["kubectl", "-n", namespace, "create", "secret", "generic", name]
        for k, v in literals.items():
            args += [f"--from-literal={k}={v}"]
        args += ["--dry-run=client", "-o", "yaml"]
        render = subprocess.run(args, capture_output=True, text=True)
        if render.returncode != 0:
            die(f"rendering secret {namespace}/{name}: {render.stderr}")
        apply = subprocess.run(["kubectl", "apply", "-f", "-"], input=render.stdout,
                                capture_output=True, text=True)
        if apply.returncode != 0:
            die(f"applying secret {namespace}/{name}: {apply.stderr}")
        print(f"  secret {namespace}/{name}: {apply.stdout.strip()}")

    def get_or_create(list_path, create_path, query, payload, patch_existing=None):
        status, res = api("GET", f"{list_path}?{query}")
        if status != 200:
            die(f"GET {list_path}?{query} -> {status} {res}")
        results = res.get("results", [])
        if results:
            obj = results[0]
            if patch_existing:
                status, obj2 = api("PATCH", f"{create_path}{obj['pk']}/", patch_existing)
                if status not in (200, 201):
                    die(f"PATCH {create_path}{obj['pk']}/ -> {status} {obj2}")
                return obj2
            return obj
        status, obj = api("POST", create_path, payload)
        if status not in (200, 201):
            die(f"POST {create_path} -> {status} {obj}")
        return obj

    # -----------------------------------------------------------------------------
    print("[1/5] Ensuring custom 'groups' scope mapping exists...")
    groups_mapping = get_or_create(
        "/api/v3/propertymappings/provider/scope/",
        "/api/v3/propertymappings/provider/scope/",
        "scope_name=groups",
        {
            "name": "homelab: groups claim",
            "scope_name": "groups",
            "expression": (
                "return {\"groups\": [group.name for group in request.user.ak_groups.all()]}"
            ),
        },
    )
    GROUPS_MAPPING_PK = groups_mapping["pk"]

    # Fetch the standard openid/email/profile mapping pks (shipped by default).
    status, res = api("GET", "/api/v3/propertymappings/provider/scope/")
    by_scope = {m["scope_name"]: m["pk"] for m in res["results"]}
    SCOPE_PKS = [by_scope["openid"], by_scope["email"], by_scope["profile"], GROUPS_MAPPING_PK]

    status, res = api("GET", "/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent")
    AUTHORIZATION_FLOW_PK = res["results"][0]["pk"]
    status, res = api("GET", "/api/v3/flows/instances/?slug=default-provider-invalidation-flow")
    INVALIDATION_FLOW_PK = res["results"][0]["pk"]
    status, res = api("GET", "/api/v3/crypto/certificatekeypairs/?has_key=true")
    SIGNING_KEY_PK = res["results"][0]["pk"]

    # -----------------------------------------------------------------------------
    print("[2/5] Ensuring groups homelab-admins / grafana-admins exist...")
    homelab_admins = get_or_create(
        "/api/v3/core/groups/", "/api/v3/core/groups/",
        "name=homelab-admins",
        {"name": "homelab-admins", "is_superuser": True},
    )
    grafana_admins = get_or_create(
        "/api/v3/core/groups/", "/api/v3/core/groups/",
        "name=grafana-admins",
        {"name": "grafana-admins", "is_superuser": False},
    )

    # -----------------------------------------------------------------------------
    print("[3/5] Ensuring user 'rock' exists with admin group membership...")
    status, res = api("GET", "/api/v3/core/users/?username=rock")
    rock_password = None
    if res.get("results"):
        rock = res["results"][0]
        status, rock = api("PATCH", f"/api/v3/core/users/{rock['pk']}/", {
            "groups": [homelab_admins["pk"], grafana_admins["pk"]],
            "is_active": True,
        })
        if status not in (200, 201):
            die(f"PATCH user rock -> {status} {rock}")
        print("  rock already exists, group membership synced (password unchanged)")
    else:
        rock_password = gen_secret(24)
        status, rock = api("POST", "/api/v3/core/users/", {
            "username": "rock",
            "name": "Rock",
            "is_active": True,
            "groups": [homelab_admins["pk"], grafana_admins["pk"]],
            "path": "users",
            "type": "internal",
        })
        if status not in (200, 201):
            die(f"POST user rock -> {status} {rock}")
        status, pw_res = api("POST", f"/api/v3/core/users/{rock['pk']}/set_password/",
                              {"password": rock_password})
        if status not in (200, 204):
            die(f"set_password for rock -> {status} {pw_res}")
        kubectl_create_secret("iam", "rock-credentials", {
            "username": "rock",
            "password": rock_password,
        })
        print("  rock created, credentials stored in iam/rock-credentials")

    # -----------------------------------------------------------------------------
    print("[4/5] Ensuring OAuth2 providers + applications for grafana/minio/forgejo/argocd...")

    SERVICES = {
        "grafana": {
            "client_secret_source": ("logging", "grafana-oidc", "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET"),
            "redirect_uris": ["https://grafana.riotpiao.com/login/generic_oauth"],
            "launch_url": "https://grafana.riotpiao.com",
            "display_name": "Grafana",
        },
        "minio": {
            "client_secret_source": ("storage", "minio-oidc", "MINIO_IDENTITY_OPENID_CLIENT_SECRET"),
            "redirect_uris": ["https://minio.riotpiao.com/oauth_callback"],
            "launch_url": "https://minio.riotpiao.com",
            "display_name": "MinIO",
        },
        "forgejo": {
            # No secret exists yet for forgejo - generate + store on first run.
            "client_secret_source": ("cicd", "forgejo-oidc", "CLIENT_SECRET"),
            "generate_if_missing": True,
            "redirect_uris": [
                "https://forgejo.riotpiao.com/user/oauth2/authentik/callback",
                "https://forgejo.riotpiao.com/user/oauth2/openidconnect/callback",
            ],
            "launch_url": "https://forgejo.riotpiao.com",
            "display_name": "Forgejo",
        },
        "argocd": {
            # oidc-secret uses hyphenated keys (client-id/client-secret) per
            # argocd-values.yaml's `$oidc-secret:client-id` / `:client-secret` refs.
            "client_secret_source": ("argocd", "oidc-secret", "client-secret"),
            "generate_if_missing": True,
            "extra_secret_literals": {"client-id": "argocd"},
            "redirect_uris": ["https://argocd.riotpiao.com/auth/callback"],
            "launch_url": "https://argocd.riotpiao.com",
            "display_name": "Argo CD",
        },
    }

    app_pks_for_binding = []

    for name, cfg in SERVICES.items():
        ns, secret_name, key = cfg["client_secret_source"]
        client_secret = kubectl_get_secret_key(ns, secret_name, key)
        if client_secret is None:
            if not cfg.get("generate_if_missing"):
                print(f"  WARNING: {ns}/{secret_name} key {key} not found and "
                      f"generate_if_missing not set for '{name}' - skipping provider/app")
                continue
            client_secret = gen_secret(40)
            literals = {key: client_secret}
            literals.update(cfg.get("extra_secret_literals", {}))
            kubectl_create_secret(ns, secret_name, literals)
            print(f"  {name}: generated new client secret -> {ns}/{secret_name}")
        else:
            print(f"  {name}: using existing client secret from {ns}/{secret_name}")

        provider = get_or_create(
            "/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/",
            f"name={name}",
            {
                "name": name,
                "client_id": name,
                "client_secret": client_secret,
                "client_type": "confidential",
                "authorization_flow": AUTHORIZATION_FLOW_PK,
                "invalidation_flow": INVALIDATION_FLOW_PK,
                "signing_key": SIGNING_KEY_PK,
                "property_mappings": SCOPE_PKS,
                "sub_mode": "hashed_user_id",
                "include_claims_in_id_token": True,
                "redirect_uris": [
                    {"matching_mode": "strict", "url": u} for u in cfg["redirect_uris"]
                ],
            },
            # Keep the redirect_uris/mappings in sync on re-run, but never touch
            # client_secret again once created (that's the source of truth in the
            # k8s Secret, and re-sending it here is harmless/idempotent anyway).
            patch_existing={
                "property_mappings": SCOPE_PKS,
                "redirect_uris": [
                    {"matching_mode": "strict", "url": u} for u in cfg["redirect_uris"]
                ],
            },
        )

        application = get_or_create(
            "/api/v3/core/applications/", "/api/v3/core/applications/",
            f"slug={name}",
            {
                "name": cfg["display_name"],
                "slug": name,
                "provider": provider["pk"],
                "meta_launch_url": cfg["launch_url"],
            },
            patch_existing={
                "provider": provider["pk"],
                "meta_launch_url": cfg["launch_url"],
            },
        )
        app_pks_for_binding.append((name, application["pk"]))
        print(f"  {name}: provider pk={provider['pk']} application pk={application['pk']}")

    # -----------------------------------------------------------------------------
    print("[5/5] Binding homelab-admins to every application (guaranteed access for rock)...")
    for name, app_pk in app_pks_for_binding:
        get_or_create(
            "/api/v3/policies/bindings/", "/api/v3/policies/bindings/",
            f"target={app_pk}&group={homelab_admins['pk']}",
            {
                "target": app_pk,
                "group": homelab_admins["pk"],
                "order": 0,
                "enabled": True,
            },
        )
        print(f"  {name}: homelab-admins bound")

    print("\nDone. Summary:")
    print("  groups:  homelab-admins (superuser), grafana-admins")
    print("  user:    rock -> homelab-admins + grafana-admins")
    print(f"  apps:    {', '.join(n for n, _ in app_pks_for_binding)}")
    if rock_password:
        print("  NOTE: rock's password was generated this run - see")
        print("  kubectl -n iam get secret rock-credentials -o jsonpath='{.data.password}' | base64 -d")
kind: ConfigMap
metadata:
  name: authentik-provision-script
  namespace: iam
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: authentik-key-rotation
  namespace: iam
spec:
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - command:
            - sh
            - -c
            - rustc /scripts/rotate_key.rs -o /tmp/rotate_key && /tmp/rotate_key
            env:
            - name: AUTHENTIK_BASE_URL
              value: http://authentik-server.iam.svc.cluster.local
            - name: AUTHENTIK_BOOTSTRAP_TOKEN
              valueFrom:
                secretKeyRef:
                  key: AUTHENTIK_BOOTSTRAP_TOKEN
                  name: authentik-key-rotation-token
            image: rust:1.82-slim
            name: rotate
            volumeMounts:
            - mountPath: /scripts
              name: script
          restartPolicy: OnFailure
          volumes:
          - configMap:
              name: key-rotation-script
            name: script
  schedule: 0 0 1 */3 *
---
apiVersion: batch/v1
kind: Job
metadata:
  annotations:
    argocd.argoproj.io/hook: PostSync
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
  name: authentik-provision
  namespace: iam
spec:
  backoffLimit: 3
  template:
    spec:
      containers:
      - command:
        - /bin/sh
        - -c
        - |
          set -e
          echo "waiting for authentik-server..."
          until wget -q -O /dev/null http://authentik-server.iam.svc.cluster.local/-/health/ready/ 2>/dev/null; do
            sleep 5
          done
          echo "installing kubectl..."
          apk add --no-cache curl >/dev/null
          KVER=$(curl -sL https://dl.k8s.io/release/stable.txt)
          curl -sLo /usr/local/bin/kubectl "https://dl.k8s.io/release/${KVER}/bin/linux/amd64/kubectl"
          chmod +x /usr/local/bin/kubectl
          echo "running provisioning script..."
          python3 /script/authentik-provision.py
        env:
        - name: AUTHENTIK_BOOTSTRAP_TOKEN
          valueFrom:
            secretKeyRef:
              key: AUTHENTIK_BOOTSTRAP_TOKEN
              name: authentik-secrets
        image: python:3.12-alpine
        name: provision
        securityContext:
          allowPrivilegeEscalation: false
          capabilities:
            drop:
            - ALL
        volumeMounts:
        - mountPath: /script
          name: script
      restartPolicy: Never
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        seccompProfile:
          type: RuntimeDefault
      serviceAccountName: authentik-provisioner
      volumes:
      - configMap:
          name: authentik-provision-script
        name: script
  ttlSecondsAfterFinished: 600 locally before pushing -
confirms all 5 RoleBindings land in their correct distinct namespaces
(iam/cicd/argocd/logging/storage) and every resource renders as valid YAML.
2026-07-21 16:33:53 -07:00
Story Crater Bot d602ed8c78 feat(iam): automate Authentik OAuth provisioning + create admin user rock
Adds k8s/security/iam/authentik-provision-job.yaml - a PostSync hook Job
(reruns every ArgoCD sync via hook-delete-policy: BeforeHookCreation) that
replaces the never-migrated setup_talos_iam.sh / provision_oidc.py workflow
(both referenced helmfile + a Python script that no longer exists in this
repo - OAuth was never actually provisioned since the ArgoCD migration).

Idempotently creates:
  - Custom 'groups' OAuth2 scope mapping (Authentik doesn't ship one by
    default; required for ArgoCD's RBAC groups claim and Grafana's
    role_attribute_path, both of which read a groups claim from the token).
  - Groups: homelab-admins (is_superuser), grafana-admins.
  - User 'rock', member of both groups above - gets full Authentik superuser
    access, ArgoCD role:admin via the existing
     RBAC policy in argocd-values.yaml, and
    Grafana Admin role via role_attribute_path. Password generated once,
    stored in iam/rock-credentials (never rotated on re-run).
  - OAuth2 providers + Applications for grafana, minio, forgejo, argocd.
    Client secrets read from existing Secrets (grafana-oidc, minio-oidc) or
    generated once and written out (forgejo-oidc, argocd's oidc-secret).
  - PolicyBinding of homelab-admins -> every Application, guaranteeing rock
    access regardless of each app's default visibility.

Also fixes forgejo-values.yaml: oauth2.CLIENT_ID was set but CLIENT_SECRET
was missing entirely (oauth2 login could never have worked). Added via
extraEnv -> GITEA__oauth2__CLIENT_SECRET sourced from the new forgejo-oidc
Secret, since the oauth2: values map can't reference a Secret inline.

RBAC: dedicated ServiceAccount + ClusterRole (secrets get/list/create/update/
patch only) bound via namespace-scoped RoleBindings in iam/cicd/argocd/
logging/storage - the only 5 namespaces this job ever touches, and the only
resource type it ever touches.

NOTE: MinIO's OIDC env vars were removed from minio-tenant.yaml earlier
(blocked IAM init because the provider/app didn't exist yet -> 404 on
discovery). Now that this job creates them, re-adding MinIO's OIDC config is
a safe follow-up in a separate change.
2026-07-21 16:31:03 -07:00
Story Crater Bot 875b87cea2 fix(vault): correct api_addr to use iam namespace and add cluster_addr 2026-07-21 15:41:33 -07:00
Story Crater Bot d55e7ff31e fix(vault): use minio-cluster-hl:9000 instead of service port 2026-07-21 15:36:11 -07:00
Story Crater Bot eda152015c fix(vault): clean up S3 config with timeout 2026-07-21 15:26:54 -07:00
Story Crater Bot c9bf9f7dce fix(vault): correct S3 timeout config placement 2026-07-21 15:26:41 -07:00
Story Crater Bot 5170921eea fix(vault): add S3 session timeout to prevent hanging 2026-07-21 15:26:29 -07:00
Story Crater Bot ef348d23f4 fix(vault): use minio service on port 80 (maps to 9000) 2026-07-21 14:52:24 -07:00
Story Crater Bot 04ec157c19 fix(vault): correct MinIO endpoint to minio-cluster-hl service 2026-07-21 14:46:54 -07:00
Story Crater Bot 3e7238f71c fix(prometheus): scrapeTimeout must be <= scrapeInterval — authentik/nginx SMs (60s>30s) + global (60s>30s) blocked operator config gen, no Prometheus STS created 2026-07-21 11:08:23 -07:00
Story Crater Bot 3f4653ac56 fix(argocd): raise repo-server memory 512Mi->1Gi — OOMKilled under CMP+Helm rendering caused chronic restarts, not-ready endpoint, and cluster-wide sync 'no route to host' failures 2026-07-21 10:01:11 -07:00
Story Crater Bot da925f3101 fix(forgejo-runner): add fsGroup 1000 so runner user can write /data/.runner — register hit permission denied on root-owned Longhorn PVC 2026-07-21 09:40:59 -07:00
Story Crater Bot 2443708abb chore(ci): refresh forgejo runner registration token — prior token invalid/expired 2026-07-21 09:38:15 -07:00
Story Crater Bot 9a34c12068 fix(forgejo-runner): point at in-cluster forgejo Service :3000 not public :443 — runner i/o timeout, forgejo serves 3000 not 443 2026-07-21 09:35:32 -07:00
Story Crater Bot 4363739d59 fix(loki,vault,iam): loki minio endpoint :80 not :9000, emit vault-minio-creds via CMP, drop redundant broken authentik-migrations job 2026-07-21 09:24:52 -07:00
Story Crater Bot 0471177250 chore(ci): add SOPS-encrypted runner-token secret record for forgejo-runner registration 2026-07-21 07:55:28 -07:00
Story Crater Bot 7fb73d6a4c fix(scheduling): pin portainer+forgejo-runner to az-a, add nodeSelector to runner chart template — WFFC alone insufficient with single Longhorn node (cp-1 only) 2026-07-20 23:51:46 -07:00
Story Crater Bot e0b24c83d0 fix(storage): add longhorn-wffc WaitForFirstConsumer default SC, repoint portainer/forgejo-runner — Immediate binding placed PVCs on non-storage nodes (cp-2/cp-3), attach failed 2026-07-20 23:49:01 -07:00
Story Crater Bot 9117fd777a fix(authentik): drop redundant authentik-migrate init container — server entrypoint migrates; old-image manage migrate tripped version-history precheck on empty DB 2026-07-20 23:40:08 -07:00
Story Crater Bot 89fa87f7c1 fix(sops-cmp): grafana-admin secret needs admin-user key too — chart existingSecret requires both user and password 2026-07-20 23:07:51 -07:00
Story Crater Bot 2ec6eba9d2 fix(sops-cmp): correct loki s3 path (.loki.storage.s3), emit authentik-secrets separately, drop broken discover — merge via server/worker/migrate envFrom
Loki keys are under .loki.storage.s3 not .loki.s3 (returned null). Emit a separate
authentik-secrets Secret (not 'authentik', which the Helm chart owns) and merge it
via envFrom on server/worker/migrate. Remove discover fileName (caused MatchRepository
timeouts; app names the plugin explicitly).
2026-07-20 22:55:23 -07:00
Story Crater Bot d6f5b9ed69 feat(argocd): wire SOPS ConfigManagementPlugin properly — initContainer installs sops/yq, sidecar decrypts *.enc.yaml into app Secrets
Correct CMP setup (prior attempt used unsupported config): repoServer.initContainers
fetches sops v3.9.0 + yq v4.44.3 into a shared volume; repoServer.extraContainers
runs argocd-cmp-server with plugin.yaml from the sops-cmp-plugin ConfigMap, age key
from sops-age Secret. Plugin emits authentik/loki-s3-creds/grafana-admin/grafana-oidc
Secrets from decrypted enc files. sops-secrets Application (wave 0) uses the plugin at
repo root. Unblocks authentik/loki/grafana which were Degraded on missing secrets.
2026-07-20 13:13:47 -07:00
Story Crater Bot abaea8823b refactor(argocd): simplify secrets approach — use directory source, manual Secrets for Stage 0
Reverts complex CMP plugin setup (helm chart doesn't support repoServer.extraContainers).
Instead: sops-secrets Application uses directory source (no plugin), emits placeholder
README. Manually-created Secrets (grafana-admin) live in target namespaces.

Full CMP plugin work deferred to future stage. Grafana values still wired to
admin.existingSecret (no-op until Secret exists, which it now does).

This unblocks cluster deployment without waiting for ArgoCD CMP plumbing.
2026-07-20 11:49:23 -07:00
Story Crater BotandClaude Haiku 4.5 d282ae1aa0 feat(argocd): deploy SOPS CMP plugin for secret decryption — Stage 0 grafana
Adds ConfigManagementPlugin (CMP) sidecar to argocd-repoServer. Plugin decrypts
*.enc.yaml files with age key from sops-age Secret, emits plain Kubernetes Secrets.

Stage 0: grafana only (2 Secrets: grafana-oidc + new grafana-admin). Updates
grafana-values.yaml to wire admin.existingSecret (chart-native support).

CMP Application (00-secrets.yaml) syncs at wave 0 before grafana/loki/authentik.
Decryption happens on-demand during sync, no pre-built Secret commits. Stages 1-4
(loki/authentik/forgejo/temporal) extend plugin script incrementally after
verification.

Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-07-20 11:30:06 -07:00
Story Crater Bot 578a707867 feat(gitops): migrate domain to riotpiao.com, add CNPG + Forgejo HA on Redis/Postgres, wire ArgoCD apps — enables cluster rebuild after etcd wipe and unblocks the git-source chicken-egg via standalone Helm-source Applications 2026-07-19 09:29:17 -07:00
Story Crater Bot 2d7330798b refactor(k8s): Reorganize into 5-layer structure with production kustomizations 2026-07-16 14:28:19 -07:00