From 77496f9a8ecccaea86cfcaab9555e2221e7d1b43 Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Wed, 26 Aug 2026 01:47:22 -0500 Subject: [PATCH] =?UTF-8?q?feat(apps):=20onboard=20semaphore=20(Phase=206)?= =?UTF-8?q?=20=E2=80=94=20second=20CNPG-DB=20+=20OIDC-target=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semaphore (Ansible UI, docs/13 row 10): internal exposure, internet egress, config on Longhorn + external PostgreSQL, auth oidc. Reuses the CNPG-DB pattern nextcloud established (apps/nextcloud-db.yaml, commit 4be6752). - catalog/semaphore.yaml: ServiceDefinition (schema >=1.2.0 env) wiring the SEMAPHORE_DB_* connection to semaphore-db-rw.semaphore-db:5432, break-glass SEMAPHORE_ADMIN* local admin, and the FIXED base64 SEMAPHORE_ACCESS_KEY_ENCRYPTION. - apps/semaphore.yaml: rendered (render.py --no-image-automation) — Deployment, config PVC, Service, default-deny + egress/gateway/internet CNPs, HTTPRoute semaphore.home.keithjackson.dev:3000, ServiceMonitor, SLI alert. - apps/semaphore-db.yaml: CNPG Cluster in dedicated ns semaphore-db, initdb from the owner secret, inheritedMetadata app.kubernetes.io/name=semaphore-db so the dep-edge egress CNP matches. - apps/semaphore-db-app.sops.yaml + apps/semaphore-bootstrap-secret.sops.yaml: SOPS(age) owner creds (basic-auth) + mirrored db-password, break-glass admin, and the persistent access-key encryption key. OIDC (Keycloak realm client) is DEFERRED like gitea/nextcloud — comes up on the break-glass local admin; realm-client wiring is the batched follow-up. Co-Authored-By: Claude Opus 4.8 --- apps/kustomization.yaml | 4 + apps/semaphore-bootstrap-secret.sops.yaml | 41 ++++ apps/semaphore-db-app.sops.yaml | 36 +++ apps/semaphore-db.yaml | 75 ++++++ apps/semaphore.yaml | 269 ++++++++++++++++++++++ catalog/semaphore.yaml | 105 +++++++++ 6 files changed, 530 insertions(+) create mode 100644 apps/semaphore-bootstrap-secret.sops.yaml create mode 100644 apps/semaphore-db-app.sops.yaml create mode 100644 apps/semaphore-db.yaml create mode 100644 apps/semaphore.yaml create mode 100644 catalog/semaphore.yaml diff --git a/apps/kustomization.yaml b/apps/kustomization.yaml index bf75f1e..c4c126e 100644 --- a/apps/kustomization.yaml +++ b/apps/kustomization.yaml @@ -21,6 +21,10 @@ resources: - nextcloud-db.yaml # CNPG PostgreSQL Cluster (ns nextcloud-db) — first CNPG-DB app - nextcloud-db-app.sops.yaml # SOPS: CNPG owner credentials (basic-auth) for initdb - nextcloud-bootstrap-secret.sops.yaml # SOPS: break-glass admin + mirrored db-password (ns nextcloud) + - semaphore.yaml + - semaphore-db.yaml # CNPG PostgreSQL Cluster (ns semaphore-db) — second CNPG-DB app + - semaphore-db-app.sops.yaml # SOPS: CNPG owner credentials (basic-auth) for initdb + - semaphore-bootstrap-secret.sops.yaml # SOPS: break-glass admin + mirrored db-password + access-key-encryption (ns semaphore) # Gitea's headless-install env + break-glass admin postStart hook are now first-class in the # ServiceDefinition (catalog/gitea.yaml env/lifecycle, schema >=1.2.0) and rendered inline into # apps/gitea.yaml, so the former gitea-bootstrap-patch.yaml strategic-merge patch is retired. diff --git a/apps/semaphore-bootstrap-secret.sops.yaml b/apps/semaphore-bootstrap-secret.sops.yaml new file mode 100644 index 0000000..18095d2 --- /dev/null +++ b/apps/semaphore-bootstrap-secret.sops.yaml @@ -0,0 +1,41 @@ +# Semaphore break-glass admin + DB password + access-key encryption key (WS-APPS, Phase 6). +# SOPS-encrypted (age). +# admin-*: break-glass local admin (docs/15 availability caveat), created on first-boot. +# db-password: MIRRORS semaphore-db-app/password (ns semaphore-db) so the app can read the DB +# credential from its own namespace (a Pod cannot read a Secret in another namespace). +# access-key-encryption: FIXED base64 32-byte AES key Semaphore uses to encrypt stored SSH keys / +# credentials; MUST stay stable across restarts or stored secrets become undecryptable. +# Recover: infra/devcontainer/dev.sh run sops -d apps/semaphore-bootstrap-secret.sops.yaml +apiVersion: v1 +kind: Secret +metadata: + name: semaphore-bootstrap + namespace: semaphore + labels: + app.kubernetes.io/name: semaphore +type: Opaque +stringData: + admin-username: ENC[AES256_GCM,data:uD/vO9A9MjerFg==,iv:i/NAqVD6rWg9oWWSGBrRkxzwcPp2IW1HLIKY0pdcPPg=,tag:r27tKLAJ+ydE7Ty3ccMNUw==,type:str] + admin-password: ENC[AES256_GCM,data:544nWY1UmBnlsdPJAQZJMXRumu4W9X7UXZvZ6vHN/ArjRgLXKTArOe5JkT/nNrNw,iv:AC1DDbZj2HfpU19EjzA7T+WpIaR4nZc3PNN61y8bAzU=,tag:r28LFi5w6ZeCCaRaX2jRdg==,type:str] + db-password: ENC[AES256_GCM,data:ySkdvaGRCW9fMQqChs5l8dY4zCG19i4HOEinRC48mgLuh9WOrjd16T6jwFgiwB55,iv:+1gztJ22HbwgQJiolZMMWOcKIsxrBJpnruJdtxdooOU=,tag:8CfJGwyPTjDGyr0CvalXMQ==,type:str] + access-key-encryption: ENC[AES256_GCM,data:1YAMLo1nVkpJWZc+KBZ7B4ia+Y8P+xMyOhHMcEGGJOXd3o+WC5FqirLBVh4=,iv:j28ozeaDhGWDjoaxFOhn2t9YbYE7L3ah/Mfi+i2DWwY=,tag:czG9Wxc/7Nsuh1W5iATOww==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuRDlqRFJ0dk14UHluSGRP + S1pESy9PWlpMakU0aTkzNVhqUGdtbWNEdTJjCm5RNG14azJHc28rdEJ4L0RzR0xO + MGRocGZ1V1JZTENWQkN3VGZzamp2eTgKLS0tIGt6QkNNaW5hdTBJVHY0SVo5d3JX + elp0SWxHcjg0RWF3aW9mWTJHZ0lhcUEKc0rU5VilAVDleCwqq4SOF+Y9sniu1Pka + w/MGDA9THSB0b+YgWRWiX/KiimFqp0ZFODOJ9cHVwFGY71+VadPwOg== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-08-26T06:45:19Z" + mac: ENC[AES256_GCM,data:5XhhTZ9BGQTIdHPNrFUxwgPRm0hOqyzdsjc2ACVSJofBho+3j7XiExGTXTyblSmT5Kv+BEym0rgOjxgKKF59CAnjEMhIoZ/3mYWegQGF8HZhVqDdPgjHmxFF93D6tLK5mQGimXILmDAYcAMRAvQoJGF16BDo4v5YeVZ1bAJZ4Ug=,iv:33cmv3YaskhbUQ+K+dcJQI9LYHKZDCtDjd2NgR5yvts=,tag:CRh/ZsPggCmqyePQe5Q0gw==,type:str] + pgp: [] + encrypted_regex: ^(data|stringData)$ + version: 3.9.4 diff --git a/apps/semaphore-db-app.sops.yaml b/apps/semaphore-db-app.sops.yaml new file mode 100644 index 0000000..64c980f --- /dev/null +++ b/apps/semaphore-db-app.sops.yaml @@ -0,0 +1,36 @@ +# Semaphore PostgreSQL owner credentials (WS-APPS, Phase 6). SOPS-encrypted (age). +# type basic-auth (keys username/password) so the CNPG semaphore-db Cluster consumes it as the +# semaphore role via spec.bootstrap.initdb.secret. The SAME password is mirrored (out of band) into +# semaphore-bootstrap/db-password in ns semaphore for the app env (cross-ns Secret reads are not +# possible). Recover: infra/devcontainer/dev.sh run sops -d apps/semaphore-db-app.sops.yaml +apiVersion: v1 +kind: Secret +metadata: + name: semaphore-db-app + namespace: semaphore-db + labels: + app.kubernetes.io/name: semaphore-db +type: kubernetes.io/basic-auth +stringData: + username: ENC[AES256_GCM,data:HHZSssc3EUBI,iv:rSQDLlUB7jJmNt/aWK192Dai02DLnUR8gUBvTjPOR2w=,tag:536QJ0eni7ywFNcGsahRGg==,type:str] + password: ENC[AES256_GCM,data:U08COmf1X+JXjdEiKLQbYLcwU9/dAY/tnr4ErJxYY1CfVT1g6O9EfPCAGr4Bcb7w,iv:FNX8fCYuiXzG4SPwqwsVQwGOCxrz7WIsoZgD7QFeF14=,tag:tzEF58j4iLF9GLW6spo8Ww==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByMk1DQXI4T1NycFRaTnpw + eXprNTRpQUlmWkMrVkJBand5UUtQYmxZT0E0CkNZMU9Ic0FuS0dLMzJQUWVBdmhU + ZmQvYjRhNWs4SVFLYzhLV1lRdDNQc1UKLS0tIEtEQmFOOWdlRkUwczB1RzRPSU5R + Y1ZFVWZnS2dZUlV1cDJTRHNSWjFrZzgKT3O1iKP40UDdecai9wygeqe1W+wWqiL/ + jxZAK0Nwk/AMCgxuNKruDsjBTJ7SLoCVn+uyp6CUdPt/Xbax/XOnIw== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-08-26T06:45:19Z" + mac: ENC[AES256_GCM,data:gpPdGR59gDiMlKtpaIaYUDArOH0ItuJ9egGC8/QNV3JHkvPgKbA0Czwy4qrVOOOi1/9FVD2i0an5O1OPHy5B9R8I1xanXjCzlz1x7+Q76xIGZHwAHPsjPJyqOefuwWFoE114dStHhNGdLN2nNnDJX2bjJHyMtKHi8CK7BNkpJxY=,iv:jV3dwb0f7c+RrqkufxOYBwh4yNlsfK2KfMh2nn9+9WI=,tag:Yg9B/q6fxz4MobsF/1agHg==,type:str] + pgp: [] + encrypted_regex: ^(data|stringData)$ + version: 3.9.4 diff --git a/apps/semaphore-db.yaml b/apps/semaphore-db.yaml new file mode 100644 index 0000000..5ae919e --- /dev/null +++ b/apps/semaphore-db.yaml @@ -0,0 +1,75 @@ +--- +# Semaphore's PostgreSQL — a CloudNativePG Cluster (CR of the CNPG CRDs from platform/cnpg), modeled +# on apps/nextcloud-db.yaml (the first CNPG-DB) / platform/keycloak/postgres-cluster.yaml. This is the +# SECOND CNPG-DB in apps/, reusing the pattern established by nextcloud. +# +# WHY A DEDICATED `semaphore-db` NAMESPACE (not co-located in `semaphore`): +# The app->db edge in catalog/semaphore.yaml (`dependencies: to: semaphore-db`) compiles to a +# CiliumNetworkPolicy whose egress selector pins BOTH the destination pod label +# (app.kubernetes.io/name=semaphore-db) AND its namespace (k8s:io.kubernetes.pod.namespace= +# semaphore-db) — cross-namespace Cilium selectors must name the namespace (catalog/render.py +# r_dependency_policies). So the DB MUST live in a namespace literally named `semaphore-db` and its +# pods MUST carry app.kubernetes.io/name=semaphore-db, injected below via inheritedMetadata. +# +# DEFAULT-DENY POSTURE — mirrors keycloak/nextcloud-db (platform/keycloak/namespace.yaml): this +# namespace is INTENTIONALLY not homelab.keithjackson.dev/app-namespace=true, so Kyverno does NOT +# generate the app default-deny baseline here and CNPG runs under Cilium's cluster-wide default-allow — +# the SAME posture the verified keycloak-db / nextcloud-db use. The semaphore SIDE keeps its +# default-deny; its rendered egress-allow opens ONLY semaphore -> semaphore-db:5432, and the DB side's +# default-allow accepts that reciprocal ingress. No app-namespace policy (auth-mode/host-namespace/ +# secret-provenance) is weakened. +apiVersion: v1 +kind: Namespace +metadata: + name: semaphore-db + labels: + # Match the Cilium egress selector's namespace pin; NOT an app-namespace (see header). + app.kubernetes.io/name: semaphore-db +--- +# Owner credentials for the CNPG cluster. SOPS-encrypted (age); type basic-auth so CNPG's initdb +# consumes it as the `semaphore` role's password instead of generating a random one — this lets the +# SAME password be mirrored into ns semaphore (semaphore-bootstrap/db-password) for the app's env, +# since a Pod cannot read a Secret in another namespace. Provided as apps/semaphore-db-app.sops.yaml. +# +# CNPG Cluster (CR of postgresql.cnpg.io installed by platform/cnpg). +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: semaphore-db + namespace: semaphore-db +spec: + # Single Postgres instance on 3-replica Longhorn is acceptable at home scale (ADR-0014, same as + # keycloak-db / nextcloud-db). The storage is already 3-way replicated by Longhorn; raise to 3 for + # Postgres-level HA as a future hardening. + instances: 1 + imageName: ghcr.io/cloudnative-pg/postgresql:16.4 + # Stamp app.kubernetes.io/name=semaphore-db onto every generated pod/service so the rendered + # semaphore egress CiliumNetworkPolicy's toEndpoints selector matches the primary pod (R-NET-02). + inheritedMetadata: + labels: + app.kubernetes.io/name: semaphore-db + storage: + storageClass: longhorn + size: 5Gi + bootstrap: + initdb: + database: semaphore + owner: semaphore + # Use the pre-provisioned owner secret (basic-auth) so its password is known + reusable by the + # app in the other namespace, instead of the random CNPG-generated one. + secret: + name: semaphore-db-app + # Emit Postgres metrics so Prometheus scrapes DB health (R-OBS-01). The CNPG operator creates the + # PodMonitor at runtime (CRD present from Phase-4 monitoring). + monitoring: + enablePodMonitor: true + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 512Mi + # --- Off-cluster DR backup (R-STATE-03) — OPERATOR HAND-OFF (OQ-BKP, shared with Velero, Phase 4) --- + # When the off-cluster backup target is chosen, add a Barman object store + a ScheduledBackup here + # (same hand-off as keycloak-db / nextcloud-db). Until then the DB is protected by 3-replica Longhorn + # + (Phase-4) Velero PVC backups of the namespace; the SD declares backup.policy daily-offcluster. diff --git a/apps/semaphore.yaml b/apps/semaphore.yaml new file mode 100644 index 0000000..d7dd09d --- /dev/null +++ b/apps/semaphore.yaml @@ -0,0 +1,269 @@ +--- +# Namespace (trust zone; default-deny + policies apply) +apiVersion: v1 +kind: Namespace +metadata: + name: semaphore + labels: + homelab.keithjackson.dev/app-namespace: 'true' + gateway-access: shared + annotations: + homelab.keithjackson.dev/backup-policy: daily-offcluster +--- +# Default-deny baseline (R-NET-01): deny all, allow only DNS +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny + namespace: semaphore + labels: + homelab.keithjackson.dev/baseline: default-deny +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress + ingress: [] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +--- +# PVC semaphore-config (R-STATE) +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: semaphore-config + namespace: semaphore + labels: + app.kubernetes.io/name: semaphore +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 1Gi +--- +# Deployment (R-PLAT-02) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: semaphore + namespace: semaphore + labels: + app.kubernetes.io/name: semaphore +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: semaphore + template: + metadata: + labels: + app.kubernetes.io/name: semaphore + homelab.keithjackson.dev/egress-class: internet + homelab.keithjackson.dev/auth-mode: oidc + annotations: + homelab.keithjackson.dev/auth-mode: oidc + homelab.keithjackson.dev/auth-groups: platform-admins + spec: + containers: + - name: semaphore + image: semaphoreui/semaphore:v2.19.10 + ports: + - containerPort: 3000 + env: + - name: SEMAPHORE_DB_DIALECT + value: postgres + - name: SEMAPHORE_DB_HOST + value: semaphore-db-rw.semaphore-db.svc.cluster.local + - name: SEMAPHORE_DB_PORT + value: '5432' + - name: SEMAPHORE_DB + value: semaphore + - name: SEMAPHORE_DB_USER + value: semaphore + - name: SEMAPHORE_DB_PASS + valueFrom: + secretKeyRef: + name: semaphore-bootstrap + key: db-password + - name: SEMAPHORE_ADMIN + valueFrom: + secretKeyRef: + name: semaphore-bootstrap + key: admin-username + - name: SEMAPHORE_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: semaphore-bootstrap + key: admin-password + - name: SEMAPHORE_ADMIN_NAME + value: Semaphore Admin + - name: SEMAPHORE_ADMIN_EMAIL + value: admin@home.keithjackson.dev + - name: SEMAPHORE_ACCESS_KEY_ENCRYPTION + valueFrom: + secretKeyRef: + name: semaphore-bootstrap + key: access-key-encryption + volumeMounts: + - name: config + mountPath: /etc/semaphore + volumes: + - name: config + persistentVolumeClaim: + claimName: semaphore-config +--- +# Service +apiVersion: v1 +kind: Service +metadata: + name: semaphore + namespace: semaphore + labels: + app.kubernetes.io/name: semaphore +spec: + selector: + app.kubernetes.io/name: semaphore + ports: + - name: port-3000 + port: 3000 + targetPort: 3000 +--- +# CiliumNetworkPolicy egress allow (R-NET-02): semaphore-allow-semaphore-db-5432 +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: semaphore-allow-semaphore-db-5432 + namespace: semaphore + labels: + app.kubernetes.io/name: semaphore +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: semaphore + egress: + - toEndpoints: + - matchLabels: + app.kubernetes.io/name: semaphore-db + k8s:io.kubernetes.pod.namespace: semaphore-db + toPorts: + - ports: + - port: '5432' + protocol: TCP +--- +# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): semaphore-allow-gateway-ingress +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: semaphore-allow-gateway-ingress + namespace: semaphore + labels: + app.kubernetes.io/name: semaphore +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: semaphore + ingress: + - fromEntities: + - ingress + toPorts: + - ports: + - port: '3000' + protocol: TCP +--- +# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): semaphore-allow-internet-egress +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: semaphore-allow-internet-egress + namespace: semaphore + labels: + app.kubernetes.io/name: semaphore +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: semaphore + egress: + - toEntities: + - world +--- +# HTTPRoute semaphore-3000 (R-TLS/R-DNS) +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: semaphore-3000 + namespace: semaphore + labels: + app.kubernetes.io/name: semaphore + annotations: + homelab.keithjackson.dev/auth-mode: oidc + homelab.keithjackson.dev/auth-groups: platform-admins +spec: + parentRefs: + - name: shared + namespace: gateway + sectionName: https + hostnames: + - semaphore.home.keithjackson.dev + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: semaphore + port: 3000 +--- +# ServiceMonitor (R-OBS-01) +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: semaphore + namespace: semaphore + labels: + release: kube-prometheus-stack + app.kubernetes.io/name: semaphore +spec: + selector: + matchLabels: + app.kubernetes.io/name: semaphore + namespaceSelector: + matchNames: + - semaphore + endpoints: + - port: metrics + interval: 30s +--- +# PrometheusRule SLI alerts (R-OBS-01) +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: semaphore-slis + namespace: semaphore + labels: + release: kube-prometheus-stack + app.kubernetes.io/name: semaphore +spec: + groups: + - name: semaphore.slis + rules: + - alert: semaphore-down + expr: up{job="semaphore"} == 0 + for: 5m + labels: + severity: critical + service: semaphore + annotations: + summary: semaphore is down + description: 'semaphore has been unreachable for 5m (SLI: up).' diff --git a/catalog/semaphore.yaml b/catalog/semaphore.yaml new file mode 100644 index 0000000..511c280 --- /dev/null +++ b/catalog/semaphore.yaml @@ -0,0 +1,105 @@ +# ServiceDefinition: semaphore +# Source of truth: spec docs/13-service-catalog.md row 10; docs/15-identity-and-access.md; docs/04. +# internal Ansible UI | egress internet | config on Longhorn + external PostgreSQL | auth oidc. +# +# Semaphore (semaphoreui/semaphore) is a web UI for running Ansible/Terraform. It is a stateless +# app over (a) its /etc/semaphore config PVC on Longhorn and (b) an external PostgreSQL — the +# SECOND CNPG-DB app, reusing the pattern nextcloud established (apps/nextcloud-db.yaml). Its DB is +# a CloudNativePG Cluster in a dedicated namespace (apps/semaphore-db.yaml). +# +# DB WIRING (schema >=1.2.0 env): the app reaches its PostgreSQL cross-namespace at +# semaphore-db-rw.semaphore-db:5432 (the CNPG read-write Service). Its owner credentials are the +# SAME password held in two SOPS secrets — semaphore-db-app (basic-auth, ns semaphore-db; consumed +# by the CNPG Cluster's initdb) and semaphore-bootstrap (ns semaphore; consumed here as db-password). +# A Pod can only read Secrets in its OWN namespace, so the shared password is duplicated across the +# two encrypted files rather than replicated cross-namespace (documented in apps/semaphore-db.yaml). +# +# PERSISTENT ENCRYPTION KEY: SEMAPHORE_ACCESS_KEY_ENCRYPTION is a base64 32-byte AES key Semaphore +# uses to encrypt the SSH keys / access credentials it stores in the DB. It MUST be FIXED and stable +# across restarts — if it changes, every stored credential becomes undecryptable. It is pinned via +# the semaphore-bootstrap SOPS secret (like gitea's SECRET_KEY), never regenerated per boot. +# Validates against schemas/servicedefinition.schema.json. +name: semaphore +kind: container +source: + image: semaphoreui/semaphore:v2.19.10 # pinned recent stable tag (R-UPD-01); update.range governs advancement +env: + # --- PostgreSQL connection (the CNPG-DB pattern). DIALECT/HOST/PORT/DB/USER are literals; PASS is + # sourced from the in-namespace SOPS secret (the CNPG -app secret lives in ns semaphore-db, + # unreachable by a Pod secretKeyRef, so the same value is mirrored into semaphore-bootstrap). --- + - name: SEMAPHORE_DB_DIALECT + value: postgres + - name: SEMAPHORE_DB_HOST + value: semaphore-db-rw.semaphore-db.svc.cluster.local + - name: SEMAPHORE_DB_PORT + value: "5432" + - name: SEMAPHORE_DB + value: semaphore + - name: SEMAPHORE_DB_USER + value: semaphore + - name: SEMAPHORE_DB_PASS + valueFrom: + secretKeyRef: + name: semaphore-bootstrap + key: db-password + # --- Break-glass local admin (docs/15 availability caveat): created on first-boot so the operator + # is never locked out by an IdP outage. Username/password sourced from the SOPS secret; the + # display name + email are non-sensitive literals. --- + - name: SEMAPHORE_ADMIN + valueFrom: + secretKeyRef: + name: semaphore-bootstrap + key: admin-username + - name: SEMAPHORE_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: semaphore-bootstrap + key: admin-password + - name: SEMAPHORE_ADMIN_NAME + value: Semaphore Admin + - name: SEMAPHORE_ADMIN_EMAIL + value: admin@home.keithjackson.dev + # --- Persistent access-key encryption key (see header): FIXED base64 32-byte key from SOPS so the + # SSH keys / credentials Semaphore stores in the DB stay decryptable across restarts. --- + - name: SEMAPHORE_ACCESS_KEY_ENCRYPTION + valueFrom: + secretKeyRef: + name: semaphore-bootstrap + key: access-key-encryption +exposure: internal # LAN-only (internal DNS + Gateway on LAN); not edge-forwarded (docs/09; R-DNS/R-NET) +egress: internet # normal WAN egress (docs/03; R-NET-05) — clones repos, installs Galaxy roles +storage: + - name: config + size: 1Gi + class: longhorn # config (generated config.json, tmp inventories) on the 3-replica tier + mountPath: /etc/semaphore # Semaphore reads/writes its config under /etc/semaphore (R-STATE-02) +dependencies: + - to: semaphore-db # explicit allow: Semaphore -> its PostgreSQL (docs/13 graph; R-NET-02). + port: 5432 # Renders the egress half; the DB namespace mirrors keycloak-db (no + protocol: tcp # default-deny), so Cilium's default-allow accepts the reciprocal ingress. +ingressRoutes: + - host: semaphore.home.keithjackson.dev + port: 3000 # Semaphore HTTP web/API; wildcard TLS terminates at the shared Gateway (docs/08/09) +auth: + mode: oidc # native Keycloak OIDC client (docs/13 SSO row, docs/15 "Native OIDC"; R-IDP-02) + groups: + - platform-admins # access scoped to an authorized group (R-IDP-06) + # NOTE (OIDC follow-up + break-glass): like Gitea/Nextcloud (docs/15 "Availability caveat"), + # Semaphore keeps a LOCAL admin as the break-glass path so an IdP outage never locks the operator + # out. At initial deploy it comes up on that local admin (SEMAPHORE_ADMIN* env above). The Keycloak + # `semaphore` realm client + Semaphore's OIDC provider config (config.json `oidc_providers`) is a + # DEFERRED follow-up: wiring it means editing the reconciled realm import in platform-config (a + # malformed edit fails the whole platform-config apply / can leave the realm import PENDING) — so it + # is deferred rather than risking the running Keycloak realm / Flux source. This records the target + # integration; it embeds no secret. +update: + channel: stable # R-UPD-01 + range: ">=2.19.0 <3.0.0" # stay on the 2.x stable line + cadence: "0 4 * * *" + autoRollbackOnFailedHealth: true # R-UPD-03 +observability: + metrics: true + slis: + - up # automation UI down is a critical alert (docs/10; R-OBS-01) +backup: + policy: daily-offcluster # DR-critical: its DB holds automation credentials, backed up off-cluster (docs/13, docs/04; R-STATE-03)