Compare commits

..

8 Commits

Author SHA1 Message Date
Andrew Stoltz
13d8ca8c1a infra: export appset and mirror alert polish 2026-06-10 16:36:18 -05:00
Andrew Stoltz
b0a3ef7448 monitoring: delay PiManagerDown duplicate pages 2026-06-10 16:23:49 -05:00
Andrew Stoltz
81ac1f3e4f authentik: align volumeClaimTemplates TypeMeta with SSA-created live object
StatefulSet/authentik-postgres has been eternally OutOfSync since ~Sprint 65
even though 'kubectl diff --server-side --field-manager=argocd-controller'
shows zero real change. The STS was created via ServerSideApply, so the live
object carries apiVersion/kind inside volumeClaimTemplates[]; git omitting
them makes ArgoCD's normalized diff disagree forever. Declare them in git.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:18:29 -05:00
b842738a0e Merge pull request 'Sprint 63 Cx-10: align hardening probe paths with live routes' (#44) from codex/s63-cx10 into main
Sprint 63 Cx-10 live-proof fix after Traefik curls found three stale probe-path annotations. Local lint 100/100; git diff --check clean; no Gitea statuses attached.
2026-06-05 03:02:14 +00:00
Andrew Stoltz
f0cb7a5e81 fix(hardening): align probe-path annotations with live health routes 2026-06-04 22:01:04 -05:00
ac0f665323 Merge pull request 'Draft: Sprint 62 Cx-10 broader exposure hardening' (#43) from codex/s62-cx10 into main
Sprint 63 Cx-10 reconcile-first merge after local lint proof: 100/100 passed, no Gitea statuses attached, CRLF diff check clean.
2026-06-05 02:51:37 +00:00
Andrew Stoltz
c4b08f41ab feat(infra): prestage broader app exposure hardening 2026-06-04 18:14:22 -05:00
Andrew Stoltz
417d3830ae test(lint): reconcile baseline infra assertions 2026-06-04 18:02:32 -05:00
10 changed files with 707 additions and 459 deletions

View File

@@ -2,6 +2,22 @@
Infrastructure manifests for ArgoCD. An `ApplicationSet` in `argocd` namespace watches the `apps/*` directories in this repo and creates one `Application` per subdir (prefixed `infra-<name>`). Infrastructure manifests for ArgoCD. An `ApplicationSet` in `argocd` namespace watches the `apps/*` directories in this repo and creates one `Application` per subdir (prefixed `infra-<name>`).
## Root GitOps ApplicationSet
`argocd/applicationset-bluejay-infra.yaml` is the root of this GitOps tree, but
it is **NOT self-managed** by ArgoCD. Apply it manually when the root generator
or sync policy changes:
```bash
kubectl -n argocd apply -f argocd/applicationset-bluejay-infra.yaml
```
Keep the per-StatefulSet `ignoreDifferences` entries in that file synced with
the live ApplicationSet. They intentionally cover `zabbix-postgres`,
`guac-mysql`, `matrix-postgres`, and `authentik-postgres` so ArgoCD does not
loop forever on server-side-apply `volumeClaimTemplates` status drift. Every new
StatefulSet with `volumeClaimTemplates` needs its own entry appended.
## Adding a new service to the cluster ## Adding a new service to the cluster
Follow these steps in order. **Step 1 must run before step 3** — if you skip it, cert-manager HTTP-01 will silently fail for ~2h per cert (exponential backoff) until someone diagnoses the DNS. Follow these steps in order. **Step 1 must run before step 3** — if you skip it, cert-manager HTTP-01 will silently fail for ~2h per cert (exponential backoff) until someone diagnoses the DNS.

View File

@@ -1,448 +1,453 @@
# Authentik OIDC backend # Authentik OIDC backend
# ArgoCD-managed. BlueJay Lab. # ArgoCD-managed. BlueJay Lab.
# #
# Stack: # Stack:
# - PostgreSQL 16 StatefulSet (single replica, Longhorn RWO 5Gi) # - PostgreSQL 16 StatefulSet (single replica, Longhorn RWO 5Gi)
# - Redis 7 Deployment (no persistence — session/cache only) # - Redis 7 Deployment (no persistence — session/cache only)
# - Authentik server + worker Deployments (image ghcr.io/goauthentik/server:2024.12.3) # - Authentik server + worker Deployments (image ghcr.io/goauthentik/server:2024.12.3)
# - Media PVC shared between server + worker (Longhorn RWO 2Gi) # - Media PVC shared between server + worker (Longhorn RWO 2Gi)
# - Certificate via step-ca-acme ClusterIssuer # - Certificate via step-ca-acme ClusterIssuer
# - Traefik IngressRoute at id.iamworkin.lan # - Traefik IngressRoute at id.iamworkin.lan
# #
# Secrets come from 1Password item "authentik-credentials" (IAmWorkin vault, id y6i74ch22q5wvm7znquq4nhhcu) # Secrets come from 1Password item "authentik-credentials" (IAmWorkin vault, id y6i74ch22q5wvm7znquq4nhhcu)
# via the OnePasswordItem CRD, materialized into k8s Secret authentik/authentik-credentials. # via the OnePasswordItem CRD, materialized into k8s Secret authentik/authentik-credentials.
# #
# Why the discovery URL is /application/o/pimanager/ : Authentik issues per-application OIDC providers. # Why the discovery URL is /application/o/pimanager/ : Authentik issues per-application OIDC providers.
# The pimanager OIDC application/provider is created after the cluster pods are healthy (manual or # The pimanager OIDC application/provider is created after the cluster pods are healthy (manual or
# via API once the bootstrap token is available — see Notes substrate). # via API once the bootstrap token is available — see Notes substrate).
--- ---
apiVersion: v1 apiVersion: v1
kind: Namespace kind: Namespace
metadata: metadata:
name: authentik name: authentik
labels: labels:
app.kubernetes.io/part-of: bluejay-infra app.kubernetes.io/part-of: bluejay-infra
--- ---
# 1Password operator pulls the authentik-credentials item into a k8s Secret of the same name. # 1Password operator pulls the authentik-credentials item into a k8s Secret of the same name.
# Field labels in 1P become Secret keys: AUTHENTIK_SECRET_KEY, POSTGRES_PASSWORD, REDIS_PASSWORD, # Field labels in 1P become Secret keys: AUTHENTIK_SECRET_KEY, POSTGRES_PASSWORD, REDIS_PASSWORD,
# BOOTSTRAP_ADMIN_PASSWORD, BOOTSTRAP_ADMIN_TOKEN, BOOTSTRAP_ADMIN_EMAIL. # BOOTSTRAP_ADMIN_PASSWORD, BOOTSTRAP_ADMIN_TOKEN, BOOTSTRAP_ADMIN_EMAIL.
apiVersion: onepassword.com/v1 apiVersion: onepassword.com/v1
kind: OnePasswordItem kind: OnePasswordItem
metadata: metadata:
name: authentik-credentials name: authentik-credentials
namespace: authentik namespace: authentik
spec: spec:
itemPath: "vaults/IAmWorkin/items/authentik-credentials" itemPath: "vaults/IAmWorkin/items/authentik-credentials"
--- ---
# Shared media volume for server + worker pods. # Shared media volume for server + worker pods.
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
name: authentik-media name: authentik-media
namespace: authentik namespace: authentik
spec: spec:
storageClassName: longhorn storageClassName: longhorn
accessModes: [ReadWriteOnce] accessModes: [ReadWriteOnce]
resources: resources:
requests: requests:
storage: 2Gi storage: 2Gi
--- ---
# PostgreSQL 16 StatefulSet — Authentik's primary store. # PostgreSQL 16 StatefulSet — Authentik's primary store.
apiVersion: apps/v1 apiVersion: apps/v1
kind: StatefulSet kind: StatefulSet
metadata: metadata:
name: authentik-postgres name: authentik-postgres
namespace: authentik namespace: authentik
labels: labels:
app: authentik-postgres app: authentik-postgres
argocd.argoproj.io/instance: infra-authentik argocd.argoproj.io/instance: infra-authentik
spec: spec:
persistentVolumeClaimRetentionPolicy: persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain whenDeleted: Retain
whenScaled: Retain whenScaled: Retain
podManagementPolicy: OrderedReady podManagementPolicy: OrderedReady
serviceName: authentik-postgres serviceName: authentik-postgres
replicas: 1 replicas: 1
revisionHistoryLimit: 10 revisionHistoryLimit: 10
selector: selector:
matchLabels: matchLabels:
app: authentik-postgres app: authentik-postgres
template: template:
metadata: metadata:
labels: labels:
app: authentik-postgres app: authentik-postgres
spec: spec:
containers: containers:
- name: postgres - name: postgres
image: postgres:16-alpine image: postgres:16-alpine
ports: ports:
- containerPort: 5432 - containerPort: 5432
name: postgres name: postgres
env: env:
- name: POSTGRES_USER - name: POSTGRES_USER
value: authentik value: authentik
- name: POSTGRES_PASSWORD - name: POSTGRES_PASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: authentik-credentials name: authentik-credentials
key: POSTGRES_PASSWORD key: POSTGRES_PASSWORD
- name: POSTGRES_DB - name: POSTGRES_DB
value: authentik value: authentik
- name: POSTGRES_INITDB_ARGS - name: POSTGRES_INITDB_ARGS
value: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C" value: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
- name: PGDATA - name: PGDATA
value: /var/lib/postgresql/data/pgdata value: /var/lib/postgresql/data/pgdata
readinessProbe: readinessProbe:
exec: exec:
command: ["pg_isready", "-U", "authentik"] command: ["pg_isready", "-U", "authentik"]
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 5 periodSeconds: 5
livenessProbe: livenessProbe:
exec: exec:
command: ["pg_isready", "-U", "authentik"] command: ["pg_isready", "-U", "authentik"]
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 30 periodSeconds: 30
resources: resources:
requests: { cpu: 100m, memory: 256Mi } requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 1000m, memory: 1Gi } limits: { cpu: 1000m, memory: 1Gi }
volumeMounts: volumeMounts:
- name: pgdata - name: pgdata
mountPath: /var/lib/postgresql/data mountPath: /var/lib/postgresql/data
volumeClaimTemplates: volumeClaimTemplates:
- metadata: # apiVersion/kind included deliberately: this STS was created via ArgoCD ServerSideApply,
name: pgdata # so the live object carries PVC TypeMeta inside volumeClaimTemplates; omitting it here
spec: # leaves the app eternally OutOfSync even though kubectl SSA dry-run shows no change.
storageClassName: longhorn - apiVersion: v1
accessModes: [ReadWriteOnce] kind: PersistentVolumeClaim
volumeMode: Filesystem metadata:
resources: name: pgdata
requests: spec:
storage: 5Gi storageClassName: longhorn
accessModes: [ReadWriteOnce]
--- volumeMode: Filesystem
apiVersion: v1 resources:
kind: Service requests:
metadata: storage: 5Gi
name: authentik-postgres
namespace: authentik ---
spec: apiVersion: v1
clusterIP: None kind: Service
selector: metadata:
app: authentik-postgres name: authentik-postgres
ports: namespace: authentik
- name: postgres spec:
port: 5432 clusterIP: None
targetPort: 5432 selector:
app: authentik-postgres
--- ports:
# Redis 7 — session storage + Celery broker. No persistence needed (cache). - name: postgres
apiVersion: apps/v1 port: 5432
kind: Deployment targetPort: 5432
metadata:
name: authentik-redis ---
namespace: authentik # Redis 7 — session storage + Celery broker. No persistence needed (cache).
labels: apiVersion: apps/v1
app: authentik-redis kind: Deployment
argocd.argoproj.io/instance: infra-authentik metadata:
spec: name: authentik-redis
replicas: 1 namespace: authentik
strategy: labels:
type: Recreate app: authentik-redis
selector: argocd.argoproj.io/instance: infra-authentik
matchLabels: spec:
app: authentik-redis replicas: 1
template: strategy:
metadata: type: Recreate
labels: selector:
app: authentik-redis matchLabels:
spec: app: authentik-redis
containers: template:
- name: redis metadata:
image: redis:7-alpine labels:
args: app: authentik-redis
- "--save" spec:
- "" containers:
- "--appendonly" - name: redis
- "no" image: redis:7-alpine
- "--requirepass" args:
- "$(REDIS_PASSWORD)" - "--save"
env: - ""
- name: REDIS_PASSWORD - "--appendonly"
valueFrom: - "no"
secretKeyRef: - "--requirepass"
name: authentik-credentials - "$(REDIS_PASSWORD)"
key: REDIS_PASSWORD env:
ports: - name: REDIS_PASSWORD
- containerPort: 6379 valueFrom:
name: redis secretKeyRef:
readinessProbe: name: authentik-credentials
tcpSocket: { port: 6379 } key: REDIS_PASSWORD
initialDelaySeconds: 5 ports:
periodSeconds: 5 - containerPort: 6379
livenessProbe: name: redis
tcpSocket: { port: 6379 } readinessProbe:
initialDelaySeconds: 30 tcpSocket: { port: 6379 }
periodSeconds: 30 initialDelaySeconds: 5
resources: periodSeconds: 5
requests: { cpu: 50m, memory: 64Mi } livenessProbe:
limits: { cpu: 500m, memory: 256Mi } tcpSocket: { port: 6379 }
initialDelaySeconds: 30
--- periodSeconds: 30
apiVersion: v1 resources:
kind: Service requests: { cpu: 50m, memory: 64Mi }
metadata: limits: { cpu: 500m, memory: 256Mi }
name: authentik-redis
namespace: authentik ---
spec: apiVersion: v1
selector: kind: Service
app: authentik-redis metadata:
ports: name: authentik-redis
- name: redis namespace: authentik
port: 6379 spec:
targetPort: 6379 selector:
app: authentik-redis
--- ports:
# Authentik server Deployment — HTTP frontend on :9000. - name: redis
apiVersion: apps/v1 port: 6379
kind: Deployment targetPort: 6379
metadata:
name: authentik-server ---
namespace: authentik # Authentik server Deployment — HTTP frontend on :9000.
labels: apiVersion: apps/v1
app: authentik-server kind: Deployment
argocd.argoproj.io/instance: infra-authentik metadata:
spec: name: authentik-server
replicas: 1 namespace: authentik
strategy: labels:
type: Recreate # shares /media RWO PVC with worker app: authentik-server
selector: argocd.argoproj.io/instance: infra-authentik
matchLabels: spec:
app: authentik-server replicas: 1
template: strategy:
metadata: type: Recreate # shares /media RWO PVC with worker
labels: selector:
app: authentik-server matchLabels:
spec: app: authentik-server
securityContext: template:
# Authentik image runs as uid 1000 "authentik" but the Longhorn PVC mounts metadata:
# root:root by default. fsGroup recursively chgrp + chmod g+rwx so the labels:
# non-root container can mkdir /media/public during the tenant_files migration. app: authentik-server
fsGroup: 1000 spec:
containers: securityContext:
- name: server # Authentik image runs as uid 1000 "authentik" but the Longhorn PVC mounts
image: ghcr.io/goauthentik/server:2024.12.3 # root:root by default. fsGroup recursively chgrp + chmod g+rwx so the
args: ["server"] # non-root container can mkdir /media/public during the tenant_files migration.
ports: fsGroup: 1000
- containerPort: 9000 containers:
name: http - name: server
- containerPort: 9443 image: ghcr.io/goauthentik/server:2024.12.3
name: https args: ["server"]
env: ports:
- name: AUTHENTIK_SECRET_KEY - containerPort: 9000
valueFrom: name: http
secretKeyRef: - containerPort: 9443
name: authentik-credentials name: https
key: AUTHENTIK_SECRET_KEY env:
- name: AUTHENTIK_REDIS__HOST - name: AUTHENTIK_SECRET_KEY
value: authentik-redis valueFrom:
- name: AUTHENTIK_REDIS__PASSWORD secretKeyRef:
valueFrom: name: authentik-credentials
secretKeyRef: key: AUTHENTIK_SECRET_KEY
name: authentik-credentials - name: AUTHENTIK_REDIS__HOST
key: REDIS_PASSWORD value: authentik-redis
- name: AUTHENTIK_POSTGRESQL__HOST - name: AUTHENTIK_REDIS__PASSWORD
value: authentik-postgres valueFrom:
- name: AUTHENTIK_POSTGRESQL__NAME secretKeyRef:
value: authentik name: authentik-credentials
- name: AUTHENTIK_POSTGRESQL__USER key: REDIS_PASSWORD
value: authentik - name: AUTHENTIK_POSTGRESQL__HOST
- name: AUTHENTIK_POSTGRESQL__PASSWORD value: authentik-postgres
valueFrom: - name: AUTHENTIK_POSTGRESQL__NAME
secretKeyRef: value: authentik
name: authentik-credentials - name: AUTHENTIK_POSTGRESQL__USER
key: POSTGRES_PASSWORD value: authentik
- name: AUTHENTIK_BOOTSTRAP_PASSWORD - name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: authentik-credentials name: authentik-credentials
key: BOOTSTRAP_ADMIN_PASSWORD key: POSTGRES_PASSWORD
- name: AUTHENTIK_BOOTSTRAP_TOKEN - name: AUTHENTIK_BOOTSTRAP_PASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: authentik-credentials name: authentik-credentials
key: BOOTSTRAP_ADMIN_TOKEN key: BOOTSTRAP_ADMIN_PASSWORD
- name: AUTHENTIK_BOOTSTRAP_EMAIL - name: AUTHENTIK_BOOTSTRAP_TOKEN
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: authentik-credentials name: authentik-credentials
key: BOOTSTRAP_ADMIN_EMAIL key: BOOTSTRAP_ADMIN_TOKEN
- name: AUTHENTIK_DISABLE_UPDATE_CHECK - name: AUTHENTIK_BOOTSTRAP_EMAIL
value: "true" valueFrom:
- name: AUTHENTIK_ERROR_REPORTING__ENABLED secretKeyRef:
value: "false" name: authentik-credentials
- name: AUTHENTIK_LOG_LEVEL key: BOOTSTRAP_ADMIN_EMAIL
value: info - name: AUTHENTIK_DISABLE_UPDATE_CHECK
# First-boot Authentik can take 3+ min on the migration phase value: "true"
# (waiting on DB lock while worker also runs migrations). Initial - name: AUTHENTIK_ERROR_REPORTING__ENABLED
# delays are generous so kubelet doesn't kill the pod mid-migration; value: "false"
# periodSeconds keeps post-startup probing responsive. - name: AUTHENTIK_LOG_LEVEL
readinessProbe: value: info
httpGet: # First-boot Authentik can take 3+ min on the migration phase
path: /-/health/ready/ # (waiting on DB lock while worker also runs migrations). Initial
port: 9000 # delays are generous so kubelet doesn't kill the pod mid-migration;
initialDelaySeconds: 60 # periodSeconds keeps post-startup probing responsive.
periodSeconds: 10 readinessProbe:
timeoutSeconds: 5 httpGet:
failureThreshold: 12 path: /-/health/ready/
livenessProbe: port: 9000
httpGet: initialDelaySeconds: 60
path: /-/health/live/ periodSeconds: 10
port: 9000 timeoutSeconds: 5
initialDelaySeconds: 300 failureThreshold: 12
periodSeconds: 30 livenessProbe:
timeoutSeconds: 10 httpGet:
failureThreshold: 3 path: /-/health/live/
startupProbe: port: 9000
httpGet: initialDelaySeconds: 300
path: /-/health/live/ periodSeconds: 30
port: 9000 timeoutSeconds: 10
initialDelaySeconds: 30 failureThreshold: 3
periodSeconds: 15 startupProbe:
timeoutSeconds: 10 httpGet:
failureThreshold: 40 # 30s + 40*15s = 10.5 min budget path: /-/health/live/
resources: port: 9000
requests: { cpu: 150m, memory: 512Mi } initialDelaySeconds: 30
limits: { cpu: 1500m, memory: 1Gi } periodSeconds: 15
volumeMounts: timeoutSeconds: 10
- name: media failureThreshold: 40 # 30s + 40*15s = 10.5 min budget
mountPath: /media resources:
volumes: requests: { cpu: 150m, memory: 512Mi }
- name: media limits: { cpu: 1500m, memory: 1Gi }
persistentVolumeClaim: volumeMounts:
claimName: authentik-media - name: media
mountPath: /media
--- volumes:
# Authentik worker Deployment — runs Celery background tasks. - name: media
apiVersion: apps/v1 persistentVolumeClaim:
kind: Deployment claimName: authentik-media
metadata:
name: authentik-worker ---
namespace: authentik # Authentik worker Deployment — runs Celery background tasks.
labels: apiVersion: apps/v1
app: authentik-worker kind: Deployment
argocd.argoproj.io/instance: infra-authentik metadata:
spec: name: authentik-worker
replicas: 1 namespace: authentik
strategy: labels:
type: Recreate # shares /media RWO PVC with server app: authentik-worker
selector: argocd.argoproj.io/instance: infra-authentik
matchLabels: spec:
app: authentik-worker replicas: 1
template: strategy:
metadata: type: Recreate # shares /media RWO PVC with server
labels: selector:
app: authentik-worker matchLabels:
spec: app: authentik-worker
securityContext: template:
# Same as server pod — non-root uid 1000 needs PVC group write. metadata:
fsGroup: 1000 labels:
containers: app: authentik-worker
- name: worker spec:
image: ghcr.io/goauthentik/server:2024.12.3 securityContext:
args: ["worker"] # Same as server pod — non-root uid 1000 needs PVC group write.
env: fsGroup: 1000
- name: AUTHENTIK_SECRET_KEY containers:
valueFrom: - name: worker
secretKeyRef: image: ghcr.io/goauthentik/server:2024.12.3
name: authentik-credentials args: ["worker"]
key: AUTHENTIK_SECRET_KEY env:
- name: AUTHENTIK_REDIS__HOST - name: AUTHENTIK_SECRET_KEY
value: authentik-redis valueFrom:
- name: AUTHENTIK_REDIS__PASSWORD secretKeyRef:
valueFrom: name: authentik-credentials
secretKeyRef: key: AUTHENTIK_SECRET_KEY
name: authentik-credentials - name: AUTHENTIK_REDIS__HOST
key: REDIS_PASSWORD value: authentik-redis
- name: AUTHENTIK_POSTGRESQL__HOST - name: AUTHENTIK_REDIS__PASSWORD
value: authentik-postgres valueFrom:
- name: AUTHENTIK_POSTGRESQL__NAME secretKeyRef:
value: authentik name: authentik-credentials
- name: AUTHENTIK_POSTGRESQL__USER key: REDIS_PASSWORD
value: authentik - name: AUTHENTIK_POSTGRESQL__HOST
- name: AUTHENTIK_POSTGRESQL__PASSWORD value: authentik-postgres
valueFrom: - name: AUTHENTIK_POSTGRESQL__NAME
secretKeyRef: value: authentik
name: authentik-credentials - name: AUTHENTIK_POSTGRESQL__USER
key: POSTGRES_PASSWORD value: authentik
- name: AUTHENTIK_DISABLE_UPDATE_CHECK - name: AUTHENTIK_POSTGRESQL__PASSWORD
value: "true" valueFrom:
- name: AUTHENTIK_ERROR_REPORTING__ENABLED secretKeyRef:
value: "false" name: authentik-credentials
- name: AUTHENTIK_LOG_LEVEL key: POSTGRES_PASSWORD
value: info - name: AUTHENTIK_DISABLE_UPDATE_CHECK
resources: value: "true"
requests: { cpu: 100m, memory: 256Mi } - name: AUTHENTIK_ERROR_REPORTING__ENABLED
limits: { cpu: 1000m, memory: 768Mi } value: "false"
volumeMounts: - name: AUTHENTIK_LOG_LEVEL
- name: media value: info
mountPath: /media resources:
volumes: requests: { cpu: 100m, memory: 256Mi }
- name: media limits: { cpu: 1000m, memory: 768Mi }
persistentVolumeClaim: volumeMounts:
claimName: authentik-media - name: media
mountPath: /media
--- volumes:
apiVersion: v1 - name: media
kind: Service persistentVolumeClaim:
metadata: claimName: authentik-media
name: authentik-server
namespace: authentik ---
spec: apiVersion: v1
selector: kind: Service
app: authentik-server metadata:
ports: name: authentik-server
- name: http namespace: authentik
port: 9000 spec:
targetPort: 9000 selector:
- name: https app: authentik-server
port: 9443 ports:
targetPort: 9443 - name: http
port: 9000
--- targetPort: 9000
# step-ca leaf certificate for id.iamworkin.lan. - name: https
# step-ca container resolver uses pfSense Unbound, so the public A record for id.iamworkin.lan port: 9443
# MUST exist before this Certificate is applied (cert-manager HTTP-01 will silently 2h-backoff targetPort: 9443
# otherwise). Added 2026-05-25 via scripts/pfsense-add-id-host.py.
apiVersion: cert-manager.io/v1 ---
kind: Certificate # step-ca leaf certificate for id.iamworkin.lan.
metadata: # step-ca container resolver uses pfSense Unbound, so the public A record for id.iamworkin.lan
name: authentik-tls # MUST exist before this Certificate is applied (cert-manager HTTP-01 will silently 2h-backoff
namespace: authentik # otherwise). Added 2026-05-25 via scripts/pfsense-add-id-host.py.
spec: apiVersion: cert-manager.io/v1
secretName: authentik-tls kind: Certificate
dnsNames: metadata:
- id.iamworkin.lan name: authentik-tls
issuerRef: namespace: authentik
name: step-ca-acme spec:
kind: ClusterIssuer secretName: authentik-tls
dnsNames:
--- - id.iamworkin.lan
apiVersion: traefik.io/v1alpha1 issuerRef:
kind: IngressRoute name: step-ca-acme
metadata: kind: ClusterIssuer
name: authentik
namespace: authentik ---
spec: apiVersion: traefik.io/v1alpha1
entryPoints: [websecure] kind: IngressRoute
routes: metadata:
- match: Host(`id.iamworkin.lan`) name: authentik
kind: Rule namespace: authentik
services: spec:
- name: authentik-server entryPoints: [websecure]
port: 9000 routes:
tls: - match: Host(`id.iamworkin.lan`)
secretName: authentik-tls kind: Rule
services:
- name: authentik-server
port: 9000
tls:
secretName: authentik-tls

View File

@@ -42,7 +42,7 @@ spec:
app: messageboard-web app: messageboard-web
annotations: annotations:
fc.flowercore.io/healthz-anon: "true" fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/healthz" fc.flowercore.io/probe-path: "/health"
prometheus.io/scrape: "true" prometheus.io/scrape: "true"
prometheus.io/port: "8080" prometheus.io/port: "8080"
prometheus.io/path: "/metrics/prometheus" prometheus.io/path: "/metrics/prometheus"

View File

@@ -525,7 +525,7 @@ spec:
app.kubernetes.io/part-of: flowercore app.kubernetes.io/part-of: flowercore
annotations: annotations:
fc.flowercore.io/healthz-anon: "true" fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/healthz" fc.flowercore.io/probe-path: "/health"
prometheus.io/scrape: "true" prometheus.io/scrape: "true"
prometheus.io/port: "5217" prometheus.io/port: "5217"
prometheus.io/path: "/metrics" prometheus.io/path: "/metrics"

View File

@@ -54,7 +54,7 @@ spec:
metadata: metadata:
annotations: annotations:
fc.flowercore.io/healthz-anon: "true" fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/healthz" fc.flowercore.io/probe-path: "/"
labels: labels:
app: updatecenter-web app: updatecenter-web
spec: spec:

View File

@@ -24,6 +24,12 @@ original Longhorn ReadWriteOnce NuGet PVC. Every other repo-scoped runner uses
two replicas with per-pod `emptyDir` caches. That is the safe backlog-drain two replicas with per-pod `emptyDir` caches. That is the safe backlog-drain
strategy: no two pods share one RWO PVC. strategy: no two pods share one RWO PVC.
Ephemeral runner pods are expected to register, run one job, deregister, and
exit so the Deployment starts a fresh pod for the next registration token. A
small amount of exit-1/restart churn from token-expiry or no-work windows is
accepted operational noise as long as jobs are not stuck queued and the
repo-scoped runner-offline alerts stay quiet.
Sprint 32 final long-tail wave adds 16 two-replica Deployments: Sprint 32 final long-tail wave adds 16 two-replica Deployments:
`FlowerCore.Knowledge`, `FlowerCore.LlmBridge`, `FlowerCore.Media`, `FlowerCore.Knowledge`, `FlowerCore.LlmBridge`, `FlowerCore.Media`,
`FlowerCore.Presentations`, `FlowerCore.RemoteDesktop`, `FlowerCore.DNS`, `FlowerCore.Presentations`, `FlowerCore.RemoteDesktop`, `FlowerCore.DNS`,

View File

@@ -843,7 +843,9 @@ data:
rules: rules:
- alert: PiManagerDown - alert: PiManagerDown
expr: up{job="pimanager-app"} == 0 expr: up{job="pimanager-app"} == 0
for: 3m # Sprint 67: delayed behind NodeDown's critical page so a powered-off
# Pi does not create the first duplicate page for the same host.
for: 8m
labels: labels:
severity: warning severity: warning
annotations: annotations:
@@ -1242,6 +1244,58 @@ data:
summary: "Marquee animation duration drifting > 10% on {{ $labels.renderer }} ({{ $labels.phase }})" summary: "Marquee animation duration drifting > 10% on {{ $labels.renderer }} ({{ $labels.phase }})"
description: "Median observed cycle duration deviates from target DurationMs by >10%. Could indicate browser tab throttling, GPU pressure, or phase-advancement bug." description: "Median observed cycle duration deviates from target DurationMs by >10%. Could indicate browser tab throttling, GPU pressure, or phase-advancement bug."
# ============================================================
# Update Center public-edge probes
# Live-mirrored from FlowerCore.Notes/scripts/monitoring/alerts.yml.
# This K8s ConfigMap is the future migration target; live Prometheus
# still reads the canonical Notes file from noc1 Podman.
# ============================================================
- name: update_center
rules:
# Critical only when the edge is genuinely unreachable. A Cloudflare
# HTTP 429 means the prober hit a rate-limit, not that real clients
# are down, so the warning rule below owns that signal.
- alert: UpdateCenterPublicEdgeDown
expr: |
(probe_success{job="probe-update-center-public-edge"} == 0)
unless on(instance)
(probe_http_status_code{job="probe-update-center-public-edge"} == 429)
for: 10m
labels:
severity: critical
service: update-center
alert_channel: irc
annotations:
summary: "Update Center public edge probe failed for {{ $labels.instance }}"
description: >-
The external probe for {{ $labels.instance }} failed for 10 minutes with a
non-2xx status that is not a rate-limit. Public Update Center clients may be
unable to fetch manifest schema metadata through Cloudflare.
runbook: >-
1. curl -sk https://{{ $labels.instance }}/api/v1/manifests/_schema
2. Verify Cloudflare DNS record is proxied and targets the current public edge IP
3. kubectl -n fc-updater get ingressroute updatecenter-web-public secret cf-origin-flowercore-io
4. Check Traefik logs for Method() or TLS secret errors
- alert: UpdateCenterPublicEdgeRateLimited
expr: probe_http_status_code{job="probe-update-center-public-edge"} == 429
for: 15m
labels:
severity: warning
service: update-center
alert_channel: irc
annotations:
summary: "Cloudflare is rate-limiting (HTTP 429) the public-edge probe for {{ $labels.instance }}"
description: >-
The blackbox prober receives HTTP 429 from Cloudflare for {{ $labels.instance }}
while the origin is healthy. This is a Cloudflare rate-limit / WAF condition on
the public hostname, not an outage.
runbook: >-
1. curl -sk https://{{ $labels.instance }}/api/v1/manifests/_schema (expect 200 from a normal client)
2. Review Cloudflare rate-limit / WAF rules for the hostname; the 5m-cadence prober is tripping a 429
3. Add a Cloudflare rate-limit exception for the prober source IP or the /api/v1/manifests/_schema path
4. Confirm whether the singular host update.flowercore.io is still required, or only updates.flowercore.io
# ============================================================================= # =============================================================================
# ConfigMap: Blackbox Exporter Configuration # ConfigMap: Blackbox Exporter Configuration
# ============================================================================= # =============================================================================

View File

@@ -114,9 +114,9 @@ spec:
app: telephony-web app: telephony-web
template: template:
metadata: metadata:
annotations: annotations:
fc.flowercore.io/healthz-anon: "true" fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/health" fc.flowercore.io/probe-path: "/health"
labels: labels:
app: telephony-web app: telephony-web
spec: spec:
@@ -164,7 +164,7 @@ spec:
ports: ports:
- containerPort: 5100 - containerPort: 5100
name: http name: http
# fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip. # fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
env: env:
- name: Telephony__Twilio__AccountSid - name: Telephony__Twilio__AccountSid
valueFrom: valueFrom:

View File

@@ -0,0 +1,74 @@
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
annotations:
argocd.argoproj.io/refresh: "true"
name: bluejay-infra
namespace: argocd
spec:
generators:
- git:
directories:
- path: apps/*
repoURL: http://gitea-clusterip.gitea.svc:3000/bluejay/bluejay-infra.git
revision: main
template:
metadata: {}
spec:
destination: {}
project: ""
goTemplate: true
goTemplateOptions:
- missingkey=error
template:
metadata:
name: infra-{{.path.basename}}
spec:
destination:
server: https://kubernetes.default.svc
ignoreDifferences:
- group: apps
jqPathExpressions:
- .spec.volumeClaimTemplates[]?.status
jsonPointers:
- /spec/volumeClaimTemplates
kind: StatefulSet
name: zabbix-postgres
namespace: zabbix
- group: apps
jqPathExpressions:
- .spec.volumeClaimTemplates[]?.status
jsonPointers:
- /spec/volumeClaimTemplates
kind: StatefulSet
name: guac-mysql
namespace: guacamole
- group: apps
jqPathExpressions:
- .spec.volumeClaimTemplates[]?.status
jsonPointers:
- /spec/volumeClaimTemplates
kind: StatefulSet
name: matrix-postgres
namespace: matrix
- group: apps
jqPathExpressions:
- .spec.volumeClaimTemplates[]?.status
jsonPointers:
- /spec/volumeClaimTemplates
kind: StatefulSet
name: authentik-postgres
namespace: authentik
project: default
source:
path: '{{.path.path}}'
repoURL: http://gitea-clusterip.gitea.svc:3000/bluejay/bluejay-infra.git
targetRevision: main
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
- RespectIgnoreDifferences=true

View File

@@ -87,10 +87,10 @@ public sealed class FleetManifestLintTests
["fc-devicemgmt"] = ("fc-devicemgmt-web", "/healthz"), ["fc-devicemgmt"] = ("fc-devicemgmt-web", "/healthz"),
["fc-library"] = ("library-web", "/health"), ["fc-library"] = ("library-web", "/health"),
["fc-llm-bridge"] = ("fc-llm-bridge", "/healthz"), ["fc-llm-bridge"] = ("fc-llm-bridge", "/healthz"),
["fc-messageboard"] = ("messageboard-web", "/healthz"), ["fc-messageboard"] = ("messageboard-web", "/health"),
["fc-retail"] = ("retail-web", "/healthz"), ["fc-retail"] = ("retail-web", "/healthz"),
["fc-ttsreader"] = ("ttsreader-web", "/healthz"), ["fc-ttsreader"] = ("ttsreader-web", "/health"),
["fc-updater"] = ("updatecenter-web", "/healthz"), ["fc-updater"] = ("updatecenter-web", "/"),
["knowledge"] = ("knowledge-web", "/healthz"), ["knowledge"] = ("knowledge-web", "/healthz"),
["telephony"] = ("telephony-web", "/health"), ["telephony"] = ("telephony-web", "/health"),
["worldbuilder"] = ("worldbuilder-web", "/healthz"), ["worldbuilder"] = ("worldbuilder-web", "/healthz"),
@@ -468,6 +468,99 @@ public sealed class FleetManifestLintTests
monitoring.Should().Contain("dedicated LinuxRunnerOffline/MacMiniRunnerOffline alerts"); monitoring.Should().Contain("dedicated LinuxRunnerOffline/MacMiniRunnerOffline alerts");
} }
[Fact]
public void GithubRunnerReadme_DocumentsAcceptedEphemeralExitChurn()
{
var readme = File.ReadAllText(Path.Combine(Inventory.BluejayRoot, "apps", "github-runner", "README.md"));
readme.Should().Contain("Ephemeral runner pods");
readme.Should().Contain("exit-1/restart churn");
readme.Should().Contain("accepted operational noise");
readme.Should().Contain("repo-scoped runner-offline alerts stay quiet");
}
[Fact]
public void Monitoring_PiManagerDownDelayAndUpdateCenterRateLimit_MatchCanonicalAlerts()
{
var notesAlerts = File.ReadAllText(Path.Combine(
Inventory.WorkspaceRoot,
"FlowerCore.Notes",
"scripts",
"monitoring",
"alerts.yml"));
var monitoring = File.ReadAllText(Path.Combine(Inventory.BluejayRoot, "apps", "monitoring", "noc-monitoring.yaml"));
notesAlerts.Should().Contain("# Sprint 67: keep this warning behind NodeDown's 5m critical page");
notesAlerts.Should().Contain("- alert: PiManagerDown");
notesAlerts.Should().Contain("for: 8m");
monitoring.Should().Contain("# Sprint 67: delayed behind NodeDown's critical page");
monitoring.Should().Contain("- alert: PiManagerDown");
monitoring.Should().Contain("for: 8m");
notesAlerts.Should().Contain("- alert: UpdateCenterPublicEdgeRateLimited");
notesAlerts.Should().Contain("expr: probe_http_status_code{job=\"probe-update-center-public-edge\"} == 429");
notesAlerts.Should().Contain("for: 15m");
monitoring.Should().Contain("- alert: UpdateCenterPublicEdgeRateLimited");
monitoring.Should().Contain("expr: probe_http_status_code{job=\"probe-update-center-public-edge\"} == 429");
monitoring.Should().Contain("for: 15m");
monitoring.Should().Contain("severity: warning");
}
[Fact]
public void ApplicationSetExport_MustRemainManualRootOfGitOpsTree()
{
var readme = File.ReadAllText(Path.Combine(Inventory.BluejayRoot, "README.md"));
var appsetPath = Path.Combine(Inventory.BluejayRoot, "argocd", "applicationset-bluejay-infra.yaml");
File.Exists(appsetPath).Should().BeTrue();
var appset = File.ReadAllText(appsetPath);
appset.Should().Contain("kind: ApplicationSet");
appset.Should().Contain("name: bluejay-infra");
appset.Should().NotContain("\nstatus:");
appset.Should().NotContain("managedFields:");
readme.Should().Contain("root of this GitOps tree");
readme.Should().Contain("NOT self-managed");
readme.Should().Contain("kubectl -n argocd apply -f argocd/applicationset-bluejay-infra.yaml");
}
[Fact]
public void ApplicationSetExport_MustDiscoverAppsDirectoryOnMain()
{
var appset = File.ReadAllText(Path.Combine(Inventory.BluejayRoot, "argocd", "applicationset-bluejay-infra.yaml"));
appset.Should().Contain("path: apps/*");
appset.Should().Contain("revision: main");
appset.Should().Contain("repoURL: http://gitea-clusterip.gitea.svc:3000/bluejay/bluejay-infra.git");
appset.Should().Contain("path: '{{.path.path}}'");
appset.Should().Contain("targetRevision: main");
appset.Should().Contain("ServerSideApply=true");
appset.Should().Contain("RespectIgnoreDifferences=true");
}
[Fact]
public void ApplicationSetExport_MustPreserveStatefulSetIgnoreDifferences()
{
var appset = File.ReadAllText(Path.Combine(Inventory.BluejayRoot, "argocd", "applicationset-bluejay-infra.yaml"));
appset.Should().Contain("jsonPointers:");
appset.Should().Contain("- /spec/volumeClaimTemplates");
appset.Should().Contain(".spec.volumeClaimTemplates[]?.status");
Regex.Matches(appset, "kind: StatefulSet").Should().HaveCount(4);
foreach (var (name, ns) in new[]
{
("zabbix-postgres", "zabbix"),
("guac-mysql", "guacamole"),
("matrix-postgres", "matrix"),
("authentik-postgres", "authentik"),
})
{
appset.Should().Contain($"name: {name}");
appset.Should().Contain($"namespace: {ns}");
}
}
[Fact] [Fact]
public void Monitoring_BlackboxTargetsForOidcSensitiveServices_MustUseAnonymousHealthRoutesWhenAvailable() public void Monitoring_BlackboxTargetsForOidcSensitiveServices_MustUseAnonymousHealthRoutesWhenAvailable()
{ {