feat(apps): onboard semaphore (Phase 6) — second CNPG-DB + OIDC-target app

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 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 4.8 committed 2026-08-26 01:47:22 -05:00
1 parent 4be6752c71
commit 77496f9a8e
6 files changed
+530

No files matched your search

+4
View File
@@ -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.
+41
View File
@@ -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
+36
View File
@@ -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
+75
View File
@@ -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.
+269
View File
@@ -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: [email protected]
- 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).'
+105
View File
@@ -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: [email protected]
# --- 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)