feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
10cbe9bf2d
commit
c01a38835a
340 files changed
+25695
-1808
No files matched your search
File renamed without changes.
File renamed without changes.
@@ -5,7 +5,16 @@ metadata:
|
||||
labels:
|
||||
app: apicore-backend
|
||||
spec:
|
||||
replicas: 2
|
||||
# Ohne HPA genau ein Pod. components/api-hpa entfernt replicas, dann
|
||||
# steuert der HPA (1-2 Pods).
|
||||
replicas: 1
|
||||
# Erst der neue Pod, dann weg mit dem alten: scheitert die Migration im
|
||||
# initContainer (components/backend-roles), laeuft der alte Pod weiter.
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
selector:
|
||||
matchLabels:
|
||||
app: apicore-backend
|
||||
@@ -29,12 +38,10 @@ spec:
|
||||
claimName: apicore-data
|
||||
containers:
|
||||
- name: apicore-backend
|
||||
# Fest auf einen Build pinnen (Tag sha-<commit> aus der CI oder Digest),
|
||||
# nicht :latest - sonst gibt es kein Rollback und jeder Neustart zieht
|
||||
# ungeprueft das neueste Image. Ausrollen:
|
||||
# kubectl -n apicore set image deploy/apicore-backend \
|
||||
# apicore-backend=git.encry.de/nick.rosenloecher/apicore-backend:sha-<commit>
|
||||
image: git.encry.de/nick.rosenloecher/apicore-backend:sha-REPLACE_ME
|
||||
# Ohne Tag: den Digest setzt das Overlay (images in
|
||||
# overlays/encry/kustomization.yaml), nie :latest - sonst gibt es
|
||||
# kein Rollback und jeder Neustart zieht ungeprueft ein neues Image.
|
||||
image: git.encry.de/nick.rosenloecher/apicore-backend
|
||||
imagePullPolicy: IfNotPresent
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
@@ -50,10 +57,10 @@ spec:
|
||||
name: apicore-backend-config
|
||||
- secretRef:
|
||||
name: apicore-backend-secret
|
||||
# Container fuehrt beim Start selbst `prisma migrate deploy` aus,
|
||||
# bevor der Server Verbindungen annimmt (siehe Dockerfile CMD) -
|
||||
# startupProbe gibt dafuer bis zu 5 Minuten Zeit, ohne dass
|
||||
# startupProbe gibt dem Start bis zu 5 Minuten, ohne dass
|
||||
# liveness/readiness in der Zwischenzeit einen Neustart ausloesen.
|
||||
# Das alte Image fuehrt `prisma migrate deploy` noch im CMD aus, ab
|
||||
# components/backend-roles laeuft die Migration im initContainer.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
+18
-5
@@ -6,17 +6,30 @@ metadata:
|
||||
name: apicore-backend-secret
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Host ist der In-Cluster-Service aus deploy/k8s/postgres/service.yaml.
|
||||
# Host ist der In-Cluster-Service aus deploy/k8s/base/postgres/service.yaml.
|
||||
# Anderer Namespace als das Backend? Dann FQDN nutzen:
|
||||
# apicore-postgres.<namespace>.svc.cluster.local
|
||||
DATABASE_URL: "postgresql://apicore:change-me-postgres-password@apicore-postgres:5432/apicore?schema=public"
|
||||
# Mindestens 32 Zeichen (server-seitig erzwungen, Start bricht sonst ab) -
|
||||
# signiert jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte
|
||||
# TOTP-Secrets. Erzeugen z.B. mit: openssl rand -hex 32
|
||||
# signiert jedes Access-/MFA-/SSO-Token. TOTP- und Webhook-Secrets aus der
|
||||
# Zeit vor den eigenen Schluesseln (unten) sind noch damit verschluesselt:
|
||||
# erst nach dem rekey-Job rotieren. Erzeugen z.B. mit: openssl rand -hex 32
|
||||
JWT_ACCESS_SECRET: "change-me-to-a-random-value-at-least-32-characters-long"
|
||||
# Encrypts backup files; needed to restore them on another deployment.
|
||||
BACKUP_ENCRYPTION_KEY: "change-me-to-another-random-value-at-least-32-characters"
|
||||
JWT_REFRESH_SECRET: "change-me-refresh-secret"
|
||||
# One encryption key per purpose (TOTP secrets, webhook signing secrets,
|
||||
# backup files). Required in production: at least 32 characters each, all
|
||||
# three different from each other and from both JWT secrets.
|
||||
# BACKUP_ENCRYPTION_KEY (with MFA_/WEBHOOK_ENCRYPTION_KEY for MFA and webhook
|
||||
# secrets) is needed to restore backups on another deployment.
|
||||
# Rotation: old value to <NAME>_PREVIOUS, new <NAME>, run the rekey job,
|
||||
# then remove <NAME>_PREVIOUS (backup files are not resealed: keep
|
||||
# BACKUP_ENCRYPTION_KEY_PREVIOUS while older files must restore).
|
||||
MFA_ENCRYPTION_KEY: "change-me-to-a-third-random-value-at-least-32-characters"
|
||||
WEBHOOK_ENCRYPTION_KEY: "change-me-to-a-fourth-random-value-at-least-32-characters"
|
||||
BACKUP_ENCRYPTION_KEY: "change-me-to-another-random-value-at-least-32-characters"
|
||||
# MFA_ENCRYPTION_KEY_PREVIOUS: ""
|
||||
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS: ""
|
||||
# BACKUP_ENCRYPTION_KEY_PREVIOUS: ""
|
||||
# Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml
|
||||
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI. Wird auch fuer die
|
||||
# M365-Kalender-Synchronisation wiederverwendet - dafuer in der
|
||||
File renamed without changes.
File renamed without changes.
@@ -5,7 +5,7 @@ metadata:
|
||||
labels:
|
||||
app: apicore-frontend
|
||||
spec:
|
||||
replicas: 2
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: apicore-frontend
|
||||
@@ -22,8 +22,8 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: apicore-frontend
|
||||
# Fest auf einen Build pinnen (sha-<commit> aus der CI), siehe Backend.
|
||||
image: git.encry.de/nick.rosenloecher/apicore-frontend:sha-REPLACE_ME
|
||||
# Ohne Tag: den Digest setzt das Overlay, siehe Backend.
|
||||
image: git.encry.de/nick.rosenloecher/apicore-frontend
|
||||
imagePullPolicy: IfNotPresent
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
File renamed without changes.
@@ -0,0 +1,16 @@
|
||||
# Grundlage ohne Cluster-Werte: kein Namespace, keine Hosts, keine Digests.
|
||||
# Die liefert overlays/encry. Die Vorlagen *.example.yaml stehen bewusst nicht
|
||||
# unter resources: Secrets bleiben ausserhalb des Repos, die ConfigMaps mit
|
||||
# echten Werten liegen im Overlay.
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- backend/data-pvc.yaml
|
||||
- backend/deployment.yaml
|
||||
- backend/service.yaml
|
||||
- frontend/deployment.yaml
|
||||
- frontend/service.yaml
|
||||
- postgres/data-pvc.yaml
|
||||
- postgres/statefulset.yaml
|
||||
- postgres/service.yaml
|
||||
- postgres/backup-cronjob.yaml
|
||||
File renamed without changes.
@@ -0,0 +1,18 @@
|
||||
# Datenverzeichnis der Datenbank, wie im Cluster: statisch gebunden an das PV
|
||||
# pv-apicore-postgres (SMB 3.1.1 mit seal auf der Storage Box, uid/gid 70,
|
||||
# Retain). Das PV und sein Zugangs-Secret gehoeren zur Cluster-Infrastruktur
|
||||
# und liegen nicht in diesem Repo. storageClassName "" verhindert, dass eine
|
||||
# Default-StorageClass ein neues, leeres Volume anlegt.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: apicore-postgres-data
|
||||
labels:
|
||||
app.kubernetes.io/name: apicore-postgres
|
||||
spec:
|
||||
accessModes: [ReadWriteMany]
|
||||
storageClassName: ""
|
||||
volumeName: pv-apicore-postgres
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
File renamed without changes.
@@ -0,0 +1,14 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: apicore-postgres
|
||||
labels:
|
||||
app.kubernetes.io/name: apicore-postgres
|
||||
spec:
|
||||
clusterIP: None
|
||||
selector:
|
||||
app.kubernetes.io/name: apicore-postgres
|
||||
ports:
|
||||
- name: postgres
|
||||
port: 5432
|
||||
targetPort: postgres
|
||||
@@ -0,0 +1,132 @@
|
||||
# PostgreSQL wie im Cluster (uebernommen 2026-10-13 per kubectl get, nur
|
||||
# lesend). Selektor und Volumes eines StatefulSets sind unveraenderlich, und
|
||||
# das Repo darf die Live-Haertung nie zuruecknehmen: Aenderungen hier immer
|
||||
# erst gegen `kubectl -n apicore get sts apicore-postgres -o yaml` pruefen.
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: apicore-postgres
|
||||
labels:
|
||||
app.kubernetes.io/name: apicore-postgres
|
||||
spec:
|
||||
serviceName: apicore-postgres
|
||||
replicas: 1
|
||||
persistentVolumeClaimRetentionPolicy:
|
||||
whenDeleted: Retain
|
||||
whenScaled: Retain
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: apicore-postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: apicore-postgres
|
||||
encry.de/tier: database
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
enableServiceLinks: false
|
||||
terminationGracePeriodSeconds: 120
|
||||
securityContext:
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: media-gpu
|
||||
operator: NotIn
|
||||
values: ["true"]
|
||||
- weight: 50
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values: [rkem01]
|
||||
topologySpreadConstraints:
|
||||
- maxSkew: 1
|
||||
topologyKey: kubernetes.io/hostname
|
||||
whenUnsatisfiable: ScheduleAnyway
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
encry.de/tier: database
|
||||
volumes:
|
||||
# Statisches Volume (data-pvc.yaml), keine volumeClaimTemplates.
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: apicore-postgres-data
|
||||
# Root-Dateisystem ist nur lesbar: Socket, /tmp und Shared Memory
|
||||
# liegen in begrenzten emptyDirs.
|
||||
- name: postgres-runtime
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 16Mi
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 64Mi
|
||||
- name: dshm
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 256Mi
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:18.6-alpine
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: postgres
|
||||
containerPort: 5432
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: apicore-postgres-secret
|
||||
env:
|
||||
# Unterordner statt Mount-Root (Layout von Postgres 18): initdb
|
||||
# verlangt ein leeres Verzeichnis, der Mount-Root ist es nicht immer.
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/18/docker
|
||||
securityContext:
|
||||
# uid/gid 70 = postgres im Alpine-Image, passend zum SMB-Mount
|
||||
# (uid=70,gid=70) des Volumes.
|
||||
runAsUser: 70
|
||||
runAsGroup: 70
|
||||
runAsNonRoot: true
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql
|
||||
- name: postgres-runtime
|
||||
mountPath: /var/run/postgresql
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: dshm
|
||||
mountPath: /dev/shm
|
||||
# Bis zu 10 Minuten Start (Recovery nach Absturz), danach liveness
|
||||
# mit viel Geduld: ein Neustart mitten in einer Recovery schadet mehr.
|
||||
startupProbe:
|
||||
exec:
|
||||
command: ["/bin/sh", "-ec", 'exec pg_isready -q -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"']
|
||||
failureThreshold: 60
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["/bin/sh", "-ec", 'exec pg_isready -q -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"']
|
||||
failureThreshold: 10
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 10
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["/bin/sh", "-ec", 'exec pg_isready -q -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"']
|
||||
failureThreshold: 3
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 30m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 1Gi
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: apicore-backend
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: apicore-backend
|
||||
minReplicas: 1
|
||||
maxReplicas: 2
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
@@ -0,0 +1,16 @@
|
||||
# HPA fuer die api: 1-2 Pods bei 70 % CPU (Spec 2026-10-12 Abschnitt 7).
|
||||
# Voraussetzungen: CPU-Requests (components/backend-roles) und der Throttler
|
||||
# ueber die DB, damit Limits ueber beide Pods zaehlen. Rollout: Runbook Schritt 5.
|
||||
apiVersion: kustomize.config.k8s.io/v1alpha1
|
||||
kind: Component
|
||||
resources:
|
||||
- hpa.yaml
|
||||
patches:
|
||||
# Ohne replicas im Deployment setzt ein apply die Pod-Zahl nicht auf 1
|
||||
# zurueck, die der HPA gerade gewaehlt hat.
|
||||
- path: patch-api-replicas.yaml
|
||||
target:
|
||||
group: apps
|
||||
version: v1
|
||||
kind: Deployment
|
||||
name: apicore-backend
|
||||
@@ -0,0 +1,2 @@
|
||||
- op: remove
|
||||
path: /spec/replicas
|
||||
@@ -0,0 +1,163 @@
|
||||
# job-Rolle: je Job ein CronJob `node dist/cli.js <job>` (Spec 2026-10-12
|
||||
# Abschnitt 2). Hier steht nur, was je Job verschieden ist. Zeitzone,
|
||||
# Forbid, Aufbewahrung, Image, Umgebung, Haertung und Ressourcen setzt der
|
||||
# gemeinsame Patch in kustomization.yaml fuer alle CronJobs mit
|
||||
# app=apicore-job.
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: apicore-job-cleanup
|
||||
labels:
|
||||
app: apicore-job
|
||||
apicore.io/job: cleanup
|
||||
spec:
|
||||
schedule: "0 4 * * *"
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 1
|
||||
activeDeadlineSeconds: 1800
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: apicore-job
|
||||
apicore.io/job: cleanup
|
||||
db-client: "true"
|
||||
spec:
|
||||
containers:
|
||||
- name: job
|
||||
command: ["node", "dist/cli.js", "cleanup"]
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: apicore-job-daily-evaluation
|
||||
labels:
|
||||
app: apicore-job
|
||||
apicore.io/job: daily-evaluation
|
||||
spec:
|
||||
schedule: "30 4 * * *"
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 1
|
||||
activeDeadlineSeconds: 1800
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: apicore-job
|
||||
apicore.io/job: daily-evaluation
|
||||
db-client: "true"
|
||||
spec:
|
||||
containers:
|
||||
- name: job
|
||||
command: ["node", "dist/cli.js", "daily-evaluation"]
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: apicore-job-task-templates
|
||||
labels:
|
||||
app: apicore-job
|
||||
apicore.io/job: task-templates
|
||||
spec:
|
||||
schedule: "0 5 * * *"
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 1
|
||||
activeDeadlineSeconds: 1800
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: apicore-job
|
||||
apicore.io/job: task-templates
|
||||
db-client: "true"
|
||||
spec:
|
||||
containers:
|
||||
- name: job
|
||||
command: ["node", "dist/cli.js", "task-templates"]
|
||||
---
|
||||
# Laeuft stuendlich und prueft selbst, ob laut Zeitplan aus GUI oder Env
|
||||
# ein Backup faellig ist. Schreibt nach /data/backups (apicore-data) und
|
||||
# liest die Dokumente aus /data/documents.
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: apicore-job-app-backup
|
||||
labels:
|
||||
app: apicore-job
|
||||
apicore.io/job: app-backup
|
||||
spec:
|
||||
schedule: "0 * * * *"
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: apicore-job
|
||||
apicore.io/job: app-backup
|
||||
db-client: "true"
|
||||
spec:
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: apicore-data
|
||||
containers:
|
||||
- name: job
|
||||
command: ["node", "dist/cli.js", "app-backup"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
---
|
||||
# rekey und rekey-dry-run laufen nie nach Zeitplan (suspend, 31. Februar),
|
||||
# nur von Hand: kubectl create job ... --from=cronjob/apicore-job-rekey
|
||||
# (Runbook Schritt 4).
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: apicore-job-rekey
|
||||
labels:
|
||||
app: apicore-job
|
||||
apicore.io/job: rekey
|
||||
spec:
|
||||
schedule: "0 0 31 2 *"
|
||||
suspend: true
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 1800
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: apicore-job
|
||||
apicore.io/job: rekey
|
||||
db-client: "true"
|
||||
spec:
|
||||
containers:
|
||||
- name: job
|
||||
command: ["node", "dist/cli.js", "rekey"]
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: apicore-job-rekey-dry-run
|
||||
labels:
|
||||
app: apicore-job
|
||||
apicore.io/job: rekey-dry-run
|
||||
spec:
|
||||
schedule: "0 0 31 2 *"
|
||||
suspend: true
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 1800
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: apicore-job
|
||||
apicore.io/job: rekey-dry-run
|
||||
db-client: "true"
|
||||
spec:
|
||||
containers:
|
||||
- name: job
|
||||
command: ["node", "dist/cli.js", "rekey", "--dry-run"]
|
||||
@@ -0,0 +1,71 @@
|
||||
# Laufrollen aus einem Image (Spec 2026-10-12 Abschnitt 2): api (Patch auf
|
||||
# apicore-backend), worker (eigenes Deployment) und job (CronJobs).
|
||||
# Rollout: Runbook Schritt 3, erst nachdem das Secret die neuen Schluessel hat.
|
||||
apiVersion: kustomize.config.k8s.io/v1alpha1
|
||||
kind: Component
|
||||
resources:
|
||||
- worker-deployment.yaml
|
||||
- cronjobs.yaml
|
||||
patches:
|
||||
- path: patch-api.yaml
|
||||
# Gemeinsam fuer alle App-CronJobs; cronjobs.yaml haelt nur, was je Job
|
||||
# verschieden ist. Werte laut Plan 2026-10-13 "Global Constraints".
|
||||
- target:
|
||||
kind: CronJob
|
||||
labelSelector: app=apicore-job
|
||||
patch: |-
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: any-app-job
|
||||
spec:
|
||||
timeZone: Europe/Berlin
|
||||
concurrencyPolicy: Forbid
|
||||
startingDeadlineSeconds: 600
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
jobTemplate:
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 86400
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
containers:
|
||||
- name: job
|
||||
image: git.encry.de/nick.rosenloecher/apicore-backend
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: APICORE_ROLE
|
||||
value: job
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: apicore-backend-config
|
||||
- secretRef:
|
||||
name: apicore-backend-secret
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 192Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
@@ -0,0 +1,91 @@
|
||||
# api-Rolle des Backends (Spec 2026-10-12 Abschnitte 2, 5.3 und 7).
|
||||
# Migration und Seed laufen als initContainer statt im CMD: scheitert die
|
||||
# Migration, startet der neue Pod nicht und der alte laeuft weiter.
|
||||
# `migrate deploy` nimmt einen Advisory-Lock in der DB, darum duerfen api und
|
||||
# worker gleichzeitig starten. Den Seed fuehrt nur die api aus.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: apicore-backend
|
||||
spec:
|
||||
template:
|
||||
# Das Label db-client: "true" setzt components/network-policies, deren
|
||||
# Postgres-Policy es braucht.
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
initContainers:
|
||||
- name: migrate
|
||||
image: git.encry.de/nick.rosenloecher/apicore-backend
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["node", "node_modules/prisma/build/index.js", "migrate", "deploy"]
|
||||
env:
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
- name: CHECKPOINT_DISABLE
|
||||
value: "1"
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: apicore-backend-config
|
||||
- secretRef:
|
||||
name: apicore-backend-secret
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 192Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
- name: seed
|
||||
image: git.encry.de/nick.rosenloecher/apicore-backend
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["node", "dist/seed.js"]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: apicore-backend-config
|
||||
- secretRef:
|
||||
name: apicore-backend-secret
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 192Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
containers:
|
||||
- name: apicore-backend
|
||||
env:
|
||||
- name: APICORE_ROLE
|
||||
value: api
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 192Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
@@ -0,0 +1,127 @@
|
||||
# worker-Rolle: Event-Dispatcher, Kalender-Outbox und Webhook-Zustellung
|
||||
# (Spec 2026-10-12 Abschnitt 2). Kein Service und kein Ingress; Port 3002
|
||||
# beantwortet nur /health fuer die Probes. Alle Schleifen arbeiten mit
|
||||
# SKIP LOCKED, mehr als ein Replica waere also moeglich.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: apicore-worker
|
||||
labels:
|
||||
app: apicore-worker
|
||||
spec:
|
||||
replicas: 1
|
||||
# Wie bei der api: scheitert die Migration, laeuft der alte Worker weiter.
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
selector:
|
||||
matchLabels:
|
||||
app: apicore-worker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: apicore-worker
|
||||
db-client: "true"
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
terminationGracePeriodSeconds: 30
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
initContainers:
|
||||
# Ohne Seed: den fuehrt nur die api aus.
|
||||
- name: migrate
|
||||
image: git.encry.de/nick.rosenloecher/apicore-backend
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["node", "node_modules/prisma/build/index.js", "migrate", "deploy"]
|
||||
env:
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
- name: CHECKPOINT_DISABLE
|
||||
value: "1"
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: apicore-backend-config
|
||||
- secretRef:
|
||||
name: apicore-backend-secret
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 192Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
containers:
|
||||
- name: apicore-worker
|
||||
image: git.encry.de/nick.rosenloecher/apicore-backend
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["node", "dist/worker.js"]
|
||||
env:
|
||||
- name: APICORE_ROLE
|
||||
value: worker
|
||||
- name: WORKER_HEALTH_PORT
|
||||
value: "3002"
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: apicore-backend-config
|
||||
- secretRef:
|
||||
name: apicore-backend-secret
|
||||
ports:
|
||||
- name: health
|
||||
containerPort: 3002
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
# /health ist gesund, wenn die DB in hoechstens 5 s antwortet und
|
||||
# der letzte Dispatcher-Durchlauf hoechstens 2 Minuten her ist.
|
||||
# Darum timeoutSeconds 5 statt der Vorgabe von 1 s.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: health
|
||||
failureThreshold: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: health
|
||||
periodSeconds: 30
|
||||
failureThreshold: 3
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: health
|
||||
periodSeconds: 15
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 20m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
@@ -0,0 +1,22 @@
|
||||
# cert-manager loest HTTP-01 (ClusterIssuer letsencrypt-prod, Klasse traefik)
|
||||
# mit kurzlebigen Solver-Pods in diesem Namespace. Traefik muss sie auf 8089
|
||||
# erreichen, sonst scheitert jede Zertifikatserneuerung. Sonst niemand.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: acme-http01-solver
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
acme.cert-manager.io/http01-solver: "true"
|
||||
policyTypes: [Ingress, Egress]
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: rke2-traefik
|
||||
ports:
|
||||
- {protocol: TCP, port: 8089}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Jeder Pod darf Namen aufloesen, aber nur bei CoreDNS in kube-system.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-dns
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes: [Egress]
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- {protocol: UDP, port: 53}
|
||||
- {protocol: TCP, port: 53}
|
||||
@@ -0,0 +1,42 @@
|
||||
# API: Anfragen von Traefik und vom Frontend. Raus nur zu Postgres und per
|
||||
# 443 zu oeffentlichen Adressen (Have I Been Pwned, Open-Meteo, Entra-Login).
|
||||
# Canal kennt keine FQDN-Regeln, darum "443 ohne private Bereiche"; den
|
||||
# SSRF-Schutz fuer Webhook-Ziele behaelt die App (webhook-url-guard.ts).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: apicore-api
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: apicore-backend
|
||||
policyTypes: [Ingress, Egress]
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: rke2-traefik
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app: apicore-frontend
|
||||
ports:
|
||||
- {protocol: TCP, port: 3001}
|
||||
egress:
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: apicore-postgres
|
||||
ports:
|
||||
- {protocol: TCP, port: 5432}
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: "0.0.0.0/0"
|
||||
except: ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16", "127.0.0.0/8"]
|
||||
- ipBlock:
|
||||
cidr: "::/0"
|
||||
except: ["fc00::/7", "fe80::/10", "::1/128"]
|
||||
ports:
|
||||
- {protocol: TCP, port: 443}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Alles zu, was keine andere Policy ausdruecklich erlaubt (beide Richtungen).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes: [Ingress, Egress]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Frontend: Anfragen nur von Traefik. Raus nur zum Backend auf 3001, kein
|
||||
# Weg zu Postgres und keiner ins Internet.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: apicore-frontend
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: apicore-frontend
|
||||
policyTypes: [Ingress, Egress]
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: rke2-traefik
|
||||
ports:
|
||||
- {protocol: TCP, port: 3000}
|
||||
egress:
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app: apicore-backend
|
||||
ports:
|
||||
- {protocol: TCP, port: 3001}
|
||||
@@ -0,0 +1,18 @@
|
||||
# CronJob-Pods (App-Jobs und pg_dump): nehmen nichts an, reden nur mit
|
||||
# Postgres. Das Backup-Ziel ist ein Volume, kein Netzweg aus dem Pod.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: apicore-jobs
|
||||
spec:
|
||||
podSelector:
|
||||
matchExpressions:
|
||||
- {key: app, operator: In, values: [apicore-job, apicore-db-backup]}
|
||||
policyTypes: [Ingress, Egress]
|
||||
egress:
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: apicore-postgres
|
||||
ports:
|
||||
- {protocol: TCP, port: 5432}
|
||||
@@ -0,0 +1,19 @@
|
||||
# NetworkPolicies nach Spec 2026-10-12 Abschnitt 5.2: Default-Deny fuer
|
||||
# Ingress und Egress, danach nur die Wege, die jede Rolle braucht.
|
||||
# Kubelet-Probes erlaubt Kubernetes immer, sie brauchen keine Regel.
|
||||
# Rollout: Runbook Schritt 2 (vorher Selektoren gegen den Live-Stand pruefen).
|
||||
# Das Label db-client an der api (patch-api-db-client.yaml) aendert das
|
||||
# Pod-Template: die api startet dabei einmal neu.
|
||||
apiVersion: kustomize.config.k8s.io/v1alpha1
|
||||
kind: Component
|
||||
resources:
|
||||
- default-deny.yaml
|
||||
- allow-dns.yaml
|
||||
- frontend.yaml
|
||||
- api.yaml
|
||||
- worker.yaml
|
||||
- jobs.yaml
|
||||
- postgres.yaml
|
||||
- acme-http01-solver.yaml
|
||||
patches:
|
||||
- path: patch-api-db-client.yaml
|
||||
@@ -0,0 +1,14 @@
|
||||
# Die api darf zu Postgres. apicore-postgres laesst nur Pods mit
|
||||
# db-client: "true" herein, darum setzt diese Komponente das Label selbst:
|
||||
# schon Runbook Schritt 2 (nur NetworkPolicies) braucht es, sonst ist die
|
||||
# api von der DB abgeschnitten. Worker, Jobs und pg_dump tragen das Label in
|
||||
# ihren eigenen Manifesten.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: apicore-backend
|
||||
spec:
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
db-client: "true"
|
||||
@@ -0,0 +1,20 @@
|
||||
# Postgres: 5432 nur fuer Pods mit db-client: "true" (api, worker, Jobs,
|
||||
# pg_dump) im selben Namespace. Raus nichts ausser DNS (allow-dns).
|
||||
# Der Selektor muss zu den Pod-Labels in base/postgres/statefulset.yaml
|
||||
# passen; der Test manifest-policy.test.mjs prueft das.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: apicore-postgres
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: apicore-postgres
|
||||
policyTypes: [Ingress, Egress]
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
db-client: "true"
|
||||
ports:
|
||||
- {protocol: TCP, port: 5432}
|
||||
@@ -0,0 +1,27 @@
|
||||
# Worker: nimmt nichts an (die Probes kommen vom Kubelet). Raus zu Postgres
|
||||
# und per 443 zu oeffentlichen Adressen (Graph, Webhook-Ziele), wie die API.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: apicore-worker
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: apicore-worker
|
||||
policyTypes: [Ingress, Egress]
|
||||
egress:
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: apicore-postgres
|
||||
ports:
|
||||
- {protocol: TCP, port: 5432}
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: "0.0.0.0/0"
|
||||
except: ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16", "127.0.0.0/8"]
|
||||
- ipBlock:
|
||||
cidr: "::/0"
|
||||
except: ["fc00::/7", "fe80::/10", "::1/128"]
|
||||
ports:
|
||||
- {protocol: TCP, port: 443}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Pod-Haertung fuer das Frontend nach Spec 2026-10-12 Abschnitte 5.3 und 7.
|
||||
# Die Backend-Rollen haerten components/backend-roles mit.
|
||||
# Rollout: Runbook Schritt 3.
|
||||
apiVersion: kustomize.config.k8s.io/v1alpha1
|
||||
kind: Component
|
||||
patches:
|
||||
- path: patch-frontend.yaml
|
||||
@@ -0,0 +1,31 @@
|
||||
# Root-Dateisystem nur lesbar. Schreiben darf Next.js nur in /tmp und in
|
||||
# seinen Cache (.next/cache), beides emptyDir.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: apicore-frontend
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
- name: next-cache
|
||||
emptyDir: {}
|
||||
containers:
|
||||
- name: apicore-frontend
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: next-cache
|
||||
mountPath: /app/.next/cache
|
||||
resources:
|
||||
requests:
|
||||
cpu: 30m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 256Mi
|
||||
@@ -0,0 +1,21 @@
|
||||
# Werte fuer den Cluster. Bedeutung der Schluessel:
|
||||
# base/backend/configmap.example.yaml. Hosts und Tenant sind Platzhalter,
|
||||
# Runbook Schritt 1 uebernimmt die Live-Werte. Geheimes gehoert ins Secret
|
||||
# apicore-backend-secret, nie hierher.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: apicore-backend-config
|
||||
data:
|
||||
PORT: "3001"
|
||||
JWT_ACCESS_EXPIRES_IN: "15m"
|
||||
JWT_REFRESH_EXPIRES_IN: "7d"
|
||||
FRONTEND_ORIGIN: "https://apicore.example.com"
|
||||
DOCUMENTS_STORAGE_PATH: "/data/documents"
|
||||
BACKUP_STORAGE_PATH: "/data/backups"
|
||||
BACKUP_CRON_SCHEDULE: "0 3 * * *"
|
||||
BACKUP_RETENTION_COUNT: "14"
|
||||
COOKIE_SECURE: "true"
|
||||
TRUST_PROXY: "loopback, 10.42.0.0/16, 173.245.48.0/20, 103.21.244.0/22, 103.22.200.0/22, 103.31.4.0/22, 141.101.64.0/18, 108.162.192.0/18, 190.93.240.0/20, 188.114.96.0/20, 197.234.240.0/22, 198.41.128.0/17, 162.158.0.0/15, 104.16.0.0/13, 104.24.0.0/14, 172.64.0.0/13, 131.0.72.0/22, 2400:cb00::/32, 2606:4700::/32, 2803:f800::/32, 2405:b500::/32, 2405:8100::/32, 2a06:98c0::/29, 2c0f:f248::/32"
|
||||
OIDC_ISSUER_URL: "https://login.microsoftonline.com/<tenant-id>/v2.0"
|
||||
OIDC_REDIRECT_URI: "https://api.apicore.example.com/api/v1/auth/sso/callback"
|
||||
@@ -0,0 +1,8 @@
|
||||
# Werte fuer den Cluster, Bedeutung: base/frontend/configmap.example.yaml.
|
||||
# Der Host ist ein Platzhalter, Runbook Schritt 1 uebernimmt den Live-Wert.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: apicore-frontend-config
|
||||
data:
|
||||
API_URL: "https://api.apicore.example.com/api/v1"
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,51 @@
|
||||
# Overlay fuer den RKE2-Cluster von EnCry (Namespace apicore, Traefik mit
|
||||
# Middleware cloudflare-only). Vor jedem Rollout zeigt
|
||||
# kubectl diff -k deploy/k8s/overlays/encry
|
||||
# was sich aendert; nach dem Rollout ist der Diff leer.
|
||||
#
|
||||
# Gewollte Platzhalter, nie so anwenden:
|
||||
# - Image-Digests sha256:000...: Runbook Schritt 0 traegt die Digests der
|
||||
# CI-Builds (sha-<commit>) und von postgres:18.6-alpine ein.
|
||||
# - Hosts in ingress-*.yaml und Werte in configmap-*.yaml: Runbook Schritt 1
|
||||
# uebernimmt sie aus dem Live-Stand.
|
||||
#
|
||||
# Backend-Digest: Das neue Image (CMD nur `node dist/main.js`) geht erst
|
||||
# zusammen mit components/backend-roles live (Runbook Schritt 3). Bis dahin
|
||||
# bleibt hier der Digest des laufenden Images. Ohne APICORE_ROLE bricht das
|
||||
# neue Image den Start in Produktion absichtlich ab. APICORE_ROLE nicht von
|
||||
# Hand setzen: ohne die initContainer von backend-roles liefe dann keine
|
||||
# Migration.
|
||||
#
|
||||
# Postgres: Bis 2026-10-13 wichen die Repo-Manifeste vom Cluster ab (Repo
|
||||
# app: apicore-postgres, ohne Haertung, volumeClaimTemplates; Cluster
|
||||
# app.kubernetes.io/name, gehaertet, statisches PVC apicore-postgres-data).
|
||||
# Seit 2026-10-13 ist base/postgres der Live-Stand (StatefulSet, Service,
|
||||
# PVC), nur das Image-Digest kommt von hier. Selektor und Volumes sind
|
||||
# unveraenderlich: Aenderungen an Postgres immer zuerst gegen den Cluster
|
||||
# pruefen (kubectl diff), nie die Live-Haertung zuruecknehmen. Das PV
|
||||
# pv-apicore-postgres und sein SMB-Secret liegen nicht in diesem Repo.
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: apicore
|
||||
resources:
|
||||
- ../../base
|
||||
- configmap-backend.yaml
|
||||
- configmap-frontend.yaml
|
||||
- ingress-backend.yaml
|
||||
- ingress-frontend.yaml
|
||||
# Zielstand. Je Runbook-Schritt nur die bis dahin freigegebenen Komponenten
|
||||
# aktivieren (Schritt 1: keine). Das Auskommentieren ist nur lokal fuer
|
||||
# kubectl diff/apply - nicht committen. Die CI rendert und prueft jeden
|
||||
# Schritt (deploy/k8s/tests/render-stages.mjs).
|
||||
components:
|
||||
- ../../components/network-policies # Schritt 2
|
||||
- ../../components/pod-hardening # Schritt 3
|
||||
- ../../components/backend-roles # Schritt 3
|
||||
- ../../components/api-hpa # Schritt 5
|
||||
images:
|
||||
- name: git.encry.de/nick.rosenloecher/apicore-backend
|
||||
digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
- name: git.encry.de/nick.rosenloecher/apicore-frontend
|
||||
digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
- name: postgres
|
||||
digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
@@ -1,11 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: apicore-postgres
|
||||
spec:
|
||||
clusterIP: None
|
||||
selector:
|
||||
app: apicore-postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
@@ -1,57 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: apicore-postgres
|
||||
spec:
|
||||
serviceName: apicore-postgres
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: apicore-postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: apicore-postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:18.6-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: apicore-postgres-secret
|
||||
env:
|
||||
# Unterordner statt Mount-Root: Postgres' initdb verlangt ein
|
||||
# leeres Datenverzeichnis, der Mount-Root von PersistentVolumes
|
||||
# enthaelt aber oft ein verstecktes "lost+found"-Verzeichnis.
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"]
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"]
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: "0"
|
||||
memory: "0"
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 1Gi
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
@@ -0,0 +1,459 @@
|
||||
// Policy checks for the rendered overlay (spec 2026-10-12 sections 5.2, 5.3, 6, 7).
|
||||
// The CI renders deploy/k8s/overlays/encry and every rollout stage and hands
|
||||
// them over as JSON (see .gitea/workflows/manifests.yml). Locally:
|
||||
// kubectl kustomize deploy/k8s/overlays/encry > /tmp/rendered.yaml
|
||||
// yq ea -o=json '[.]' /tmp/rendered.yaml > /tmp/rendered.json
|
||||
// node deploy/k8s/tests/render-stages.mjs /tmp/stages
|
||||
// RENDERED_MANIFESTS=/tmp/rendered.json RENDERED_STAGES=/tmp/stages/stages.json \
|
||||
// node --test deploy/k8s/tests/manifest-policy.test.mjs
|
||||
// RENDERED_MANIFESTS is the target state (all components) and gets the full
|
||||
// security checks. RENDERED_STAGES holds every intermediate rollout stage;
|
||||
// each must keep the application reachable.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
NAMESPACE, ip, isEmptySelector, matchesSelector, modelOf, podTemplateOf, policyTypes, syntheticPod,
|
||||
} from './netpol-model.mjs';
|
||||
|
||||
const APP_PODS = ['apicore-backend', 'apicore-worker', 'apicore-job', 'apicore-frontend'];
|
||||
const DB_CLIENT_APPS = ['apicore-backend', 'apicore-worker', 'apicore-job', 'apicore-db-backup'];
|
||||
const KNOWN_JOBS = ['daily-evaluation', 'task-templates', 'cleanup', 'app-backup', 'rekey'];
|
||||
const PRIVATE_V4 = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10', '169.254.0.0/16', '127.0.0.0/8'];
|
||||
const PRIVATE_V6 = ['fc00::/7', 'fe80::/10', '::1/128'];
|
||||
const MIGRATE = ['node', 'node_modules/prisma/build/index.js', 'migrate', 'deploy'];
|
||||
const SEED = ['node', 'dist/seed.js'];
|
||||
const POSTGRES = 'StatefulSet/apicore-postgres';
|
||||
const res = (cpuReq, cpuLim, memReq, memLim) => ({
|
||||
requests: { cpu: cpuReq, memory: memReq },
|
||||
limits: { cpu: cpuLim, memory: memLim },
|
||||
});
|
||||
const RESOURCES = {
|
||||
api: res('50m', '500m', '192Mi', '512Mi'),
|
||||
worker: res('20m', '200m', '128Mi', '256Mi'),
|
||||
frontend: res('30m', '300m', '128Mi', '256Mi'),
|
||||
job: res('50m', '500m', '192Mi', '512Mi'),
|
||||
};
|
||||
const NEVER = '0 0 31 2 *';
|
||||
const CRON_JOBS = {
|
||||
cleanup: { args: ['cleanup'], schedule: '0 4 * * *', suspend: false, backoffLimit: 1, deadline: 1800 },
|
||||
'daily-evaluation': { args: ['daily-evaluation'], schedule: '30 4 * * *', suspend: false, backoffLimit: 1, deadline: 1800 },
|
||||
'task-templates': { args: ['task-templates'], schedule: '0 5 * * *', suspend: false, backoffLimit: 1, deadline: 1800 },
|
||||
'app-backup': { args: ['app-backup'], schedule: '0 * * * *', suspend: false, backoffLimit: 0, deadline: 3600 },
|
||||
rekey: { args: ['rekey'], schedule: NEVER, suspend: true, backoffLimit: 0, deadline: 1800 },
|
||||
'rekey-dry-run': { args: ['rekey', '--dry-run'], schedule: NEVER, suspend: true, backoffLimit: 0, deadline: 1800 },
|
||||
};
|
||||
// Selectors are immutable (StatefulSet) or carry live traffic (Services), so
|
||||
// they must equal what runs in the cluster (read with kubectl on 2026-10-13).
|
||||
const LIVE_SELECTORS = {
|
||||
'Deployment/apicore-backend': { app: 'apicore-backend' },
|
||||
'Deployment/apicore-frontend': { app: 'apicore-frontend' },
|
||||
'StatefulSet/apicore-postgres': { 'app.kubernetes.io/name': 'apicore-postgres' },
|
||||
};
|
||||
const LIVE_SERVICES = {
|
||||
'apicore-backend': { selector: { app: 'apicore-backend' }, ports: [{ port: 80, targetPort: 3001 }] },
|
||||
'apicore-frontend': { selector: { app: 'apicore-frontend' }, ports: [{ port: 80, targetPort: 3000 }] },
|
||||
'apicore-postgres': {
|
||||
selector: { 'app.kubernetes.io/name': 'apicore-postgres' },
|
||||
ports: [{ name: 'postgres', port: 5432, targetPort: 'postgres' }],
|
||||
},
|
||||
};
|
||||
// Live hardening of the postgres container; the repo must never weaken it.
|
||||
const POSTGRES_SECURITY = {
|
||||
runAsUser: 70,
|
||||
runAsGroup: 70,
|
||||
runAsNonRoot: true,
|
||||
readOnlyRootFilesystem: true,
|
||||
allowPrivilegeEscalation: false,
|
||||
capabilities: { drop: ['ALL'] },
|
||||
};
|
||||
// Pods created by controllers outside this repo; policies may target them.
|
||||
const SOLVER_LABELS = { 'acme.cert-manager.io/http01-solver': 'true' };
|
||||
const TRAEFIK = syntheticPod('kube-system', { 'app.kubernetes.io/name': 'rke2-traefik' });
|
||||
const KUBE_DNS = syntheticPod('kube-system', { 'k8s-app': 'kube-dns' });
|
||||
const SOLVER = syntheticPod(NAMESPACE, SOLVER_LABELS, [{ name: 'http', containerPort: 8089 }]);
|
||||
|
||||
function loadJson(variable) {
|
||||
const file = process.env[variable];
|
||||
if (!file) throw new Error(`${variable} must point to rendered manifests as JSON (see header)`);
|
||||
return JSON.parse(readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
const target = modelOf(loadJson('RENDERED_MANIFESTS').filter(Boolean));
|
||||
const stages = Object.entries(loadJson('RENDERED_STAGES')).map(([name, docs]) => [name, modelOf(docs.filter(Boolean))]);
|
||||
|
||||
function onePod(model, app) {
|
||||
const found = model.byApp(app);
|
||||
assert.equal(found.length, 1, `expected exactly one pod template with app=${app}`);
|
||||
return found[0];
|
||||
}
|
||||
const mainContainer = (pod) => pod.containers[0];
|
||||
const envOf = (container) => Object.fromEntries((container.env ?? []).map((e) => [e.name, e.value]));
|
||||
const postgresOf = (model) => {
|
||||
const postgres = model.byOwner(POSTGRES);
|
||||
assert.ok(postgres, 'postgres StatefulSet missing');
|
||||
return postgres;
|
||||
};
|
||||
|
||||
// --- target state: security --------------------------------------------------
|
||||
|
||||
test('default deny for ingress and egress exists', () => {
|
||||
const denyAll = target.policies.filter(
|
||||
(p) =>
|
||||
isEmptySelector(p.spec.podSelector) &&
|
||||
policyTypes(p).includes('Ingress') &&
|
||||
policyTypes(p).includes('Egress') &&
|
||||
(p.spec.ingress ?? []).length === 0 &&
|
||||
(p.spec.egress ?? []).length === 0,
|
||||
);
|
||||
assert.equal(denyAll.length, 1, 'expected one namespace-wide default-deny policy');
|
||||
for (const pod of target.pods) {
|
||||
assert.ok(target.policiesFor(pod, 'Ingress').length > 0, `${pod.owner} is not isolated for ingress`);
|
||||
assert.ok(target.policiesFor(pod, 'Egress').length > 0, `${pod.owner} is not isolated for egress`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every pod template can resolve DNS (UDP+TCP 53 to kube-dns)', () => {
|
||||
assert.ok(target.pods.length > 0, 'no pod templates rendered');
|
||||
const otherKubeSystemPod = syntheticPod('kube-system', { app: 'something-else' });
|
||||
for (const pod of [...target.pods, SOLVER]) {
|
||||
for (const protocol of ['UDP', 'TCP']) {
|
||||
assert.ok(target.canConnect(pod, KUBE_DNS, 53, protocol), `${pod.owner} cannot reach kube-dns on ${protocol}/53`);
|
||||
assert.ok(!target.canConnect(pod, otherKubeSystemPod, 53, protocol), `${pod.owner} reaches any kube-system pod on 53`);
|
||||
assert.ok(!target.canConnect(pod, ip('1.1.1.1'), 53, protocol), `${pod.owner} reaches an outside resolver on 53`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('postgres accepts 5432 only from db-client pods; only api, worker, jobs and db-backup carry db-client', () => {
|
||||
const postgres = postgresOf(target);
|
||||
const clients = target.pods.filter((p) => p.labels['db-client'] === 'true');
|
||||
assert.deepEqual([...new Set(clients.map((p) => p.labels.app))].sort(), [...DB_CLIENT_APPS].sort());
|
||||
|
||||
for (const client of clients) {
|
||||
assert.ok(target.canConnect(client, postgres, 5432), `${client.owner} cannot reach postgres on 5432`);
|
||||
assert.ok(!target.canConnect(client, postgres, 22), `${client.owner} reaches postgres on a port other than 5432`);
|
||||
}
|
||||
const strangers = [
|
||||
TRAEFIK,
|
||||
SOLVER,
|
||||
syntheticPod('other', { 'db-client': 'true' }),
|
||||
syntheticPod(NAMESPACE, { app: 'intruder' }),
|
||||
ip('10.42.0.99'),
|
||||
];
|
||||
for (const stranger of strangers) {
|
||||
assert.ok(!target.allowsIngress(postgres, stranger, 5432), `${stranger.owner} reaches postgres`);
|
||||
}
|
||||
for (const pod of target.pods.filter((p) => p.labels['db-client'] !== 'true' && p !== postgres)) {
|
||||
assert.ok(!target.canConnect(pod, postgres, 5432), `${pod.owner} reaches postgres without db-client`);
|
||||
}
|
||||
});
|
||||
|
||||
test('frontend has no path to 5432 and no ipBlock egress', () => {
|
||||
const frontend = onePod(target, 'apicore-frontend');
|
||||
// Egress alone must block it, whatever ingress policies exist live.
|
||||
assert.ok(!target.allowsEgress(frontend, postgresOf(target), 5432), 'frontend egress allows postgres');
|
||||
for (const address of ['10.43.0.5', '10.42.3.7', '1.1.1.1']) {
|
||||
assert.ok(!target.allowsEgress(frontend, ip(address), 5432), `frontend egress allows ${address}:5432`);
|
||||
}
|
||||
for (const policy of target.policiesFor(frontend, 'Egress')) {
|
||||
for (const rule of policy.spec.egress ?? []) {
|
||||
assert.ok((rule.to ?? []).length > 0, `${policy.metadata.name} allows egress to everything`);
|
||||
assert.ok(!rule.to.some((peer) => peer.ipBlock), `${policy.metadata.name} gives the frontend ipBlock egress`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('worker and job pods accept no ingress', () => {
|
||||
const targets = target.pods.filter((p) => ['apicore-worker', 'apicore-job', 'apicore-db-backup'].includes(p.labels.app));
|
||||
assert.ok(targets.some((p) => p.labels.app === 'apicore-worker'), 'worker missing');
|
||||
assert.ok(targets.some((p) => p.labels.app === 'apicore-job'), 'job pods missing');
|
||||
for (const pod of targets) {
|
||||
const ingressPolicies = target.policiesFor(pod, 'Ingress');
|
||||
assert.ok(ingressPolicies.length > 0, `${pod.owner} is not isolated for ingress`);
|
||||
for (const policy of ingressPolicies) {
|
||||
assert.equal((policy.spec.ingress ?? []).length, 0, `${policy.metadata.name} opens ingress to ${pod.owner}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('every 0.0.0.0/0 and ::/0 egress excludes private ranges and allows only TCP 443', () => {
|
||||
for (const policy of target.policies) {
|
||||
for (const rule of policy.spec.egress ?? []) {
|
||||
assert.ok((rule.to ?? []).length > 0, `${policy.metadata.name} has an egress rule without destinations`);
|
||||
for (const peer of rule.to.filter((p) => p.ipBlock)) {
|
||||
const { cidr, except = [] } = peer.ipBlock;
|
||||
const required = cidr === '0.0.0.0/0' ? PRIVATE_V4 : cidr === '::/0' ? PRIVATE_V6 : null;
|
||||
assert.ok(required, `${policy.metadata.name}: unexpected ipBlock ${cidr}`);
|
||||
for (const range of required) assert.ok(except.includes(range), `${policy.metadata.name}: ${cidr} lacks except ${range}`);
|
||||
assert.deepEqual(
|
||||
(rule.ports ?? []).map((p) => [p.protocol ?? 'TCP', p.port, p.endPort]),
|
||||
[['TCP', 443, undefined]],
|
||||
`${policy.metadata.name}: public egress must be TCP 443 only`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const blocked = ['10.0.0.1', '172.16.0.1', '192.168.1.10', '100.64.0.1', '169.254.169.254', '127.0.0.1', 'fc00::1', 'fe80::1', '::1'];
|
||||
for (const app of ['apicore-backend', 'apicore-worker']) {
|
||||
const pod = onePod(target, app);
|
||||
assert.ok(target.canConnect(pod, ip('1.1.1.1'), 443), `${app} cannot reach a public IPv4 on 443`);
|
||||
assert.ok(target.canConnect(pod, ip('2606:4700::1111'), 443), `${app} cannot reach a public IPv6 on 443`);
|
||||
assert.ok(!target.canConnect(pod, ip('1.1.1.1'), 80), `${app} reaches a public IP on 80`);
|
||||
for (const address of blocked) assert.ok(!target.canConnect(pod, ip(address), 443), `${app} reaches ${address}:443`);
|
||||
}
|
||||
for (const pod of [...target.pods.filter((p) => !['apicore-backend', 'apicore-worker'].includes(p.labels.app)), SOLVER]) {
|
||||
assert.ok(!target.canConnect(pod, ip('1.1.1.1'), 443), `${pod.owner} reaches the internet`);
|
||||
}
|
||||
});
|
||||
|
||||
test('traefik reaches frontend 3000 and api 3001; the api takes nothing else', () => {
|
||||
const frontend = onePod(target, 'apicore-frontend');
|
||||
const api = onePod(target, 'apicore-backend');
|
||||
assert.ok(target.canConnect(TRAEFIK, frontend, 3000), 'traefik cannot reach the frontend');
|
||||
assert.ok(target.canConnect(TRAEFIK, api, 3001), 'traefik cannot reach the api');
|
||||
assert.ok(target.canConnect(frontend, api, 3001), 'frontend cannot reach the api');
|
||||
assert.ok(!target.allowsIngress(api, syntheticPod('other', { app: 'apicore-frontend' }), 3001), 'api open to other namespaces');
|
||||
assert.ok(!target.allowsIngress(api, onePod(target, 'apicore-worker'), 3001), 'api open to the worker');
|
||||
assert.ok(!target.allowsIngress(frontend, api, 3000), 'frontend open to the api');
|
||||
assert.ok(!target.allowsIngress(api, TRAEFIK, 3002), 'api open on another port');
|
||||
});
|
||||
|
||||
test('cert-manager http01 solver pods: traefik reaches 8089, nothing else does', () => {
|
||||
assert.ok(target.canConnect(TRAEFIK, SOLVER, 8089), 'traefik cannot reach the http01 solver');
|
||||
assert.ok(!target.allowsIngress(SOLVER, TRAEFIK, 3000), 'solver open on another port');
|
||||
const others = [
|
||||
...target.pods,
|
||||
syntheticPod('other', { 'app.kubernetes.io/name': 'rke2-traefik' }),
|
||||
syntheticPod(NAMESPACE, { app: 'intruder' }),
|
||||
ip('1.1.1.1'),
|
||||
];
|
||||
for (const other of others) {
|
||||
assert.ok(!target.allowsIngress(SOLVER, other, 8089), `${other.owner} reaches the solver`);
|
||||
}
|
||||
assert.ok(!target.canConnect(SOLVER, postgresOf(target), 5432), 'solver reaches postgres');
|
||||
});
|
||||
|
||||
test('all app pods: automountServiceAccountToken false, readOnlyRootFilesystem true, /tmp emptyDir', () => {
|
||||
const appPods = target.pods.filter((p) => APP_PODS.includes(p.labels.app));
|
||||
assert.deepEqual([...new Set(appPods.map((p) => p.labels.app))].sort(), [...APP_PODS].sort());
|
||||
for (const pod of appPods) {
|
||||
assert.equal(pod.spec.automountServiceAccountToken, false, `${pod.owner} mounts the service account token`);
|
||||
assert.equal(pod.spec.securityContext?.runAsNonRoot, true, `${pod.owner} may run as root`);
|
||||
const emptyDirs = new Set((pod.spec.volumes ?? []).filter((v) => v.emptyDir).map((v) => v.name));
|
||||
for (const c of [...pod.initContainers, ...pod.containers]) {
|
||||
const where = `${pod.owner} container ${c.name}`;
|
||||
assert.equal(c.securityContext?.readOnlyRootFilesystem, true, `${where}: root filesystem writable`);
|
||||
assert.equal(c.securityContext?.allowPrivilegeEscalation, false, `${where}: privilege escalation`);
|
||||
assert.deepEqual(c.securityContext?.capabilities?.drop, ['ALL'], `${where}: capabilities not dropped`);
|
||||
const tmp = (c.volumeMounts ?? []).find((m) => m.mountPath === '/tmp');
|
||||
assert.ok(tmp && emptyDirs.has(tmp.name), `${where}: /tmp is not an emptyDir`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('cronjobs: Forbid, Europe/Berlin, command node dist/cli.js <known job>', () => {
|
||||
const cronJobs = target.docs.filter((d) => d.kind === 'CronJob');
|
||||
for (const cj of cronJobs) assert.equal(cj.spec.concurrencyPolicy, 'Forbid', `${cj.metadata.name}`);
|
||||
const appJobs = cronJobs.filter((cj) => podTemplateOf(cj).metadata?.labels?.app === 'apicore-job');
|
||||
assert.deepEqual(
|
||||
appJobs.map((cj) => cj.metadata.name).sort(),
|
||||
Object.keys(CRON_JOBS).map((name) => `apicore-job-${name}`).sort(),
|
||||
);
|
||||
for (const cj of appJobs) {
|
||||
const name = cj.metadata.name.replace(/^apicore-job-/, '');
|
||||
const want = CRON_JOBS[name];
|
||||
const template = podTemplateOf(cj);
|
||||
const container = template.spec.containers[0];
|
||||
assert.equal(template.spec.containers.length, 1, `${name}: one container`);
|
||||
assert.equal(cj.spec.timeZone, 'Europe/Berlin', `${name}: timeZone`);
|
||||
assert.equal(cj.spec.schedule, want.schedule, `${name}: schedule`);
|
||||
assert.equal(cj.spec.suspend ?? false, want.suspend, `${name}: suspend`);
|
||||
assert.equal(cj.spec.startingDeadlineSeconds, 600, `${name}: startingDeadlineSeconds`);
|
||||
assert.equal(cj.spec.successfulJobsHistoryLimit, 3, `${name}: successfulJobsHistoryLimit`);
|
||||
assert.equal(cj.spec.failedJobsHistoryLimit, 3, `${name}: failedJobsHistoryLimit`);
|
||||
assert.equal(cj.spec.jobTemplate.spec.ttlSecondsAfterFinished, 86400, `${name}: ttlSecondsAfterFinished`);
|
||||
assert.equal(cj.spec.jobTemplate.spec.backoffLimit, want.backoffLimit, `${name}: backoffLimit`);
|
||||
assert.equal(cj.spec.jobTemplate.spec.activeDeadlineSeconds, want.deadline, `${name}: activeDeadlineSeconds`);
|
||||
assert.equal(template.spec.restartPolicy, 'Never', `${name}: restartPolicy`);
|
||||
assert.equal(template.metadata.labels['apicore.io/job'], name, `${name}: apicore.io/job label`);
|
||||
assert.deepEqual(container.command, ['node', 'dist/cli.js', ...want.args], `${name}: command`);
|
||||
assert.ok(KNOWN_JOBS.includes(container.command[2]), `${name}: unknown job ${container.command[2]}`);
|
||||
assert.equal(envOf(container).APICORE_ROLE, 'job', `${name}: APICORE_ROLE`);
|
||||
}
|
||||
const backup = appJobs.find((cj) => cj.metadata.name === 'apicore-job-app-backup');
|
||||
const backupPod = podTemplateOf(backup).spec;
|
||||
const dataVolume = backupPod.volumes.find((v) => v.persistentVolumeClaim?.claimName === 'apicore-data');
|
||||
assert.ok(dataVolume, 'app-backup does not use the apicore-data claim');
|
||||
assert.ok(
|
||||
backupPod.containers[0].volumeMounts.some((m) => m.name === dataVolume.name && m.mountPath === '/data'),
|
||||
'app-backup does not mount apicore-data on /data',
|
||||
);
|
||||
});
|
||||
|
||||
test('resources match the spec table; HPA api 1–2 at 70 % CPU', () => {
|
||||
const api = onePod(target, 'apicore-backend');
|
||||
const worker = onePod(target, 'apicore-worker');
|
||||
assert.deepEqual(mainContainer(api).resources, RESOURCES.api, 'api resources');
|
||||
assert.deepEqual(mainContainer(worker).resources, RESOURCES.worker, 'worker resources');
|
||||
assert.deepEqual(mainContainer(onePod(target, 'apicore-frontend')).resources, RESOURCES.frontend, 'frontend resources');
|
||||
for (const job of target.byApp('apicore-job')) assert.deepEqual(mainContainer(job).resources, RESOURCES.job, `${job.owner} resources`);
|
||||
for (const pod of [api, worker]) {
|
||||
for (const c of pod.initContainers) assert.deepEqual(c.resources, RESOURCES.job, `${pod.owner} init ${c.name} resources`);
|
||||
}
|
||||
|
||||
const hpas = target.docs.filter((d) => d.kind === 'HorizontalPodAutoscaler');
|
||||
assert.equal(hpas.length, 1, 'expected one HPA');
|
||||
const [hpa] = hpas;
|
||||
assert.equal(hpa.apiVersion, 'autoscaling/v2');
|
||||
assert.deepEqual(hpa.spec.scaleTargetRef, { apiVersion: 'apps/v1', kind: 'Deployment', name: 'apicore-backend' });
|
||||
assert.equal(hpa.spec.minReplicas, 1);
|
||||
assert.equal(hpa.spec.maxReplicas, 2);
|
||||
assert.deepEqual(hpa.spec.metrics, [
|
||||
{ type: 'Resource', resource: { name: 'cpu', target: { type: 'Utilization', averageUtilization: 70 } } },
|
||||
]);
|
||||
assert.equal(api.doc.spec.replicas, undefined, 'the HPA owns the api replica count');
|
||||
});
|
||||
|
||||
test('api and worker run prisma migrate deploy as initContainer; only api seeds', () => {
|
||||
const api = onePod(target, 'apicore-backend');
|
||||
const worker = onePod(target, 'apicore-worker');
|
||||
assert.deepEqual(api.initContainers.map((c) => c.name), ['migrate', 'seed']);
|
||||
assert.deepEqual(worker.initContainers.map((c) => c.name), ['migrate']);
|
||||
for (const pod of [api, worker]) {
|
||||
const [migrate] = pod.initContainers;
|
||||
assert.deepEqual(migrate.command, MIGRATE, `${pod.owner}: migrate command`);
|
||||
assert.equal(envOf(migrate).HOME, '/tmp', `${pod.owner}: migrate HOME`);
|
||||
assert.equal(envOf(migrate).CHECKPOINT_DISABLE, '1', `${pod.owner}: migrate CHECKPOINT_DISABLE`);
|
||||
for (const c of pod.initContainers) assert.equal(c.image, mainContainer(pod).image, `${pod.owner}: ${c.name} image`);
|
||||
}
|
||||
assert.deepEqual(api.initContainers[1].command, SEED);
|
||||
const all = (p) => [...p.initContainers, ...p.containers];
|
||||
const seeding = target.pods.filter((p) => all(p).some((c) => c.command?.includes('dist/seed.js')));
|
||||
assert.deepEqual(seeding.map((p) => p.owner), [api.owner], 'only the api seeds');
|
||||
const migrating = target.pods.filter((p) => p.initContainers.some((c) => c.command?.includes('migrate')));
|
||||
assert.deepEqual(migrating.map((p) => p.owner).sort(), [api.owner, worker.owner].sort());
|
||||
|
||||
assert.equal(mainContainer(api).command, undefined, 'api runs the image CMD node dist/main.js');
|
||||
assert.equal(envOf(mainContainer(api)).APICORE_ROLE, 'api');
|
||||
assert.deepEqual(mainContainer(worker).command, ['node', 'dist/worker.js']);
|
||||
assert.equal(envOf(mainContainer(worker)).APICORE_ROLE, 'worker');
|
||||
assert.equal(envOf(mainContainer(worker)).WORKER_HEALTH_PORT, '3002');
|
||||
});
|
||||
|
||||
test('every image is pinned by sha256 digest', () => {
|
||||
for (const pod of target.pods) {
|
||||
for (const c of [...pod.initContainers, ...pod.containers]) {
|
||||
assert.match(c.image, /@sha256:[0-9a-f]{64}$/, `${pod.owner} container ${c.name}: ${c.image}`);
|
||||
assert.doesNotMatch(c.image, /:latest(@|$)/, `${pod.owner} container ${c.name} uses latest`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- consistency with the cluster and between objects ------------------------
|
||||
|
||||
test('workload and Service selectors equal the live cluster', () => {
|
||||
for (const [owner, selector] of Object.entries(LIVE_SELECTORS)) {
|
||||
const pod = target.byOwner(owner);
|
||||
assert.ok(pod, `${owner} missing`);
|
||||
assert.deepEqual(pod.doc.spec.selector, { matchLabels: selector }, `${owner}: selector differs from live`);
|
||||
}
|
||||
for (const [name, live] of Object.entries(LIVE_SERVICES)) {
|
||||
const service = target.docs.find((d) => d.kind === 'Service' && d.metadata.name === name);
|
||||
assert.ok(service, `Service ${name} missing`);
|
||||
assert.deepEqual(service.spec.selector, live.selector, `Service ${name}: selector differs from live`);
|
||||
const ports = service.spec.ports.map(({ name: n, port, targetPort }) => ({ ...(n ? { name: n } : {}), port, targetPort }));
|
||||
assert.deepEqual(ports, live.ports, `Service ${name}: ports differ from live`);
|
||||
}
|
||||
});
|
||||
|
||||
test('postgres keeps the live hardening and its static volume in every stage', () => {
|
||||
for (const [name, model] of [...stages, ['target', target]]) {
|
||||
const postgres = postgresOf(model);
|
||||
const { spec } = postgres;
|
||||
assert.equal(postgres.doc.spec.volumeClaimTemplates, undefined, `${name}: volumeClaimTemplates instead of the static volume`);
|
||||
assert.equal(spec.automountServiceAccountToken, false, `${name}: service account token mounted`);
|
||||
assert.equal(spec.enableServiceLinks, false, `${name}: service links enabled`);
|
||||
assert.deepEqual(spec.securityContext, { seccompProfile: { type: 'RuntimeDefault' } }, `${name}: pod securityContext`);
|
||||
assert.equal(postgres.containers.length, 1, `${name}: one container`);
|
||||
const [container] = postgres.containers;
|
||||
assert.deepEqual(container.securityContext, POSTGRES_SECURITY, `${name}: container securityContext differs from live`);
|
||||
assert.deepEqual(container.resources, res('30m', '1', '128Mi', '1Gi'), `${name}: resources differ from live`);
|
||||
for (const probe of ['startupProbe', 'livenessProbe', 'readinessProbe']) {
|
||||
assert.ok(container[probe]?.exec, `${name}: ${probe} missing`);
|
||||
}
|
||||
const volumes = Object.fromEntries((spec.volumes ?? []).map((v) => [v.name, v]));
|
||||
const mountedAt = Object.fromEntries((container.volumeMounts ?? []).map((m) => [m.mountPath, volumes[m.name]]));
|
||||
assert.equal(mountedAt['/var/lib/postgresql']?.persistentVolumeClaim?.claimName, 'apicore-postgres-data', `${name}: data volume`);
|
||||
for (const path of ['/tmp', '/var/run/postgresql', '/dev/shm']) {
|
||||
assert.ok(mountedAt[path]?.emptyDir?.sizeLimit, `${name}: ${path} is not a size-limited emptyDir`);
|
||||
}
|
||||
const claim = model.docs.find((d) => d.kind === 'PersistentVolumeClaim' && d.metadata.name === 'apicore-postgres-data');
|
||||
assert.ok(claim, `${name}: PVC apicore-postgres-data missing`);
|
||||
assert.deepEqual(
|
||||
claim.spec,
|
||||
{ accessModes: ['ReadWriteMany'], storageClassName: '', volumeName: 'pv-apicore-postgres', resources: { requests: { storage: '10Gi' } } },
|
||||
`${name}: PVC differs from the live static binding`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every NetworkPolicy selector names pods that exist', () => {
|
||||
const existing = (selector) => target.pods.some((p) => matchesSelector(selector, p.labels));
|
||||
const outsideRepo = (selector) => Object.entries(SOLVER_LABELS).every(([k, v]) => selector.matchLabels?.[k] === v);
|
||||
for (const policy of target.policies) {
|
||||
const { podSelector } = policy.spec;
|
||||
if (!isEmptySelector(podSelector) && !outsideRepo(podSelector)) {
|
||||
assert.ok(existing(podSelector), `${policy.metadata.name}: podSelector matches no rendered pod`);
|
||||
}
|
||||
const peers = [...(policy.spec.ingress ?? []).flatMap((r) => r.from ?? []), ...(policy.spec.egress ?? []).flatMap((r) => r.to ?? [])];
|
||||
for (const peer of peers.filter((p) => p.podSelector && !p.namespaceSelector && !isEmptySelector(p.podSelector))) {
|
||||
assert.ok(existing(peer.podSelector), `${policy.metadata.name}: peer ${JSON.stringify(peer.podSelector)} matches no rendered pod`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- every rollout stage keeps the application reachable ---------------------
|
||||
|
||||
test('the stage file covers several rollout stages', () => {
|
||||
assert.ok(stages.length >= 4, `expected the rollout stages, got ${stages.map(([n]) => n)}`);
|
||||
assert.ok(stages.some(([, m]) => m.policies.length === 0), 'no stage without NetworkPolicies (Runbook step 1)');
|
||||
});
|
||||
|
||||
for (const [name, model] of [...stages, ['target', target]]) {
|
||||
test(`stage ${name}: every Service selects pods that expose its target port`, () => {
|
||||
for (const service of model.docs.filter((d) => d.kind === 'Service')) {
|
||||
const selected = model.pods.filter((p) => matchesSelector({ matchLabels: service.spec.selector }, p.labels));
|
||||
assert.ok(selected.length > 0, `Service ${service.metadata.name} selects no rendered pod`);
|
||||
for (const { port, targetPort = port } of service.spec.ports) {
|
||||
const exposes = (p) =>
|
||||
p.ports.some((c) => (typeof targetPort === 'string' ? c.name === targetPort : c.containerPort === targetPort));
|
||||
assert.ok(selected.every(exposes), `Service ${service.metadata.name}: target port ${targetPort} not exposed`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`stage ${name}: DNS, ingress, the api and every db client keep working`, () => {
|
||||
const api = onePod(model, 'apicore-backend');
|
||||
const frontend = onePod(model, 'apicore-frontend');
|
||||
const postgres = postgresOf(model);
|
||||
for (const pod of model.pods) {
|
||||
for (const protocol of ['UDP', 'TCP']) {
|
||||
assert.ok(model.canConnect(pod, KUBE_DNS, 53, protocol), `${pod.owner} cannot resolve names (${protocol})`);
|
||||
}
|
||||
}
|
||||
assert.ok(model.canConnect(TRAEFIK, frontend, 3000), 'traefik cannot reach the frontend');
|
||||
assert.ok(model.canConnect(TRAEFIK, api, 3001), 'traefik cannot reach the api');
|
||||
assert.ok(model.canConnect(frontend, api, 3001), 'frontend cannot reach the api');
|
||||
assert.ok(model.canConnect(TRAEFIK, SOLVER, 8089), 'traefik cannot reach the http01 solver');
|
||||
assert.ok(model.canConnect(api, postgres, 5432), 'api cannot reach postgres');
|
||||
assert.ok(model.canConnect(api, ip('1.1.1.1'), 443), 'api cannot reach the internet on 443');
|
||||
const dbClients = model.pods.filter((p) => ['apicore-worker', 'apicore-job', 'apicore-db-backup'].includes(p.labels.app));
|
||||
for (const client of dbClients) {
|
||||
assert.ok(model.canConnect(client, postgres, 5432), `${client.owner} cannot reach postgres`);
|
||||
}
|
||||
for (const worker of model.byApp('apicore-worker')) {
|
||||
assert.ok(model.canConnect(worker, ip('1.1.1.1'), 443), 'worker cannot reach the internet on 443');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Kubernetes semantics for rendered manifests: pod templates, label
|
||||
// selectors and NetworkPolicies (the union of all policies that select a
|
||||
// pod, both the egress and the ingress side). Used by
|
||||
// manifest-policy.test.mjs to ask "can A reach B on port P".
|
||||
|
||||
export const NAMESPACE = 'apicore';
|
||||
|
||||
/** Pod template of a workload, or undefined for anything else. */
|
||||
export function podTemplateOf(doc) {
|
||||
if (doc.kind === 'CronJob') return doc.spec?.jobTemplate?.spec?.template;
|
||||
if (['Deployment', 'StatefulSet', 'DaemonSet', 'Job'].includes(doc.kind)) return doc.spec?.template;
|
||||
return doc.kind === 'Pod' ? doc : undefined;
|
||||
}
|
||||
|
||||
function podsOf(docs) {
|
||||
return docs.flatMap((doc) => {
|
||||
const template = podTemplateOf(doc);
|
||||
if (!template) return [];
|
||||
const namespace = doc.metadata.namespace ?? NAMESPACE;
|
||||
const containers = template.spec.containers ?? [];
|
||||
return [{
|
||||
kind: 'pod',
|
||||
owner: `${doc.kind}/${doc.metadata.name}`,
|
||||
doc,
|
||||
namespace,
|
||||
nsLabels: { 'kubernetes.io/metadata.name': namespace },
|
||||
labels: template.metadata?.labels ?? {},
|
||||
spec: template.spec,
|
||||
containers,
|
||||
initContainers: template.spec.initContainers ?? [],
|
||||
ports: containers.flatMap((c) => c.ports ?? []),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
/** A pod that is not rendered here (Traefik, CoreDNS, cert-manager solver). */
|
||||
export function syntheticPod(namespace, labels, ports = []) {
|
||||
return {
|
||||
kind: 'pod', owner: `synthetic/${namespace}/${JSON.stringify(labels)}`, namespace,
|
||||
nsLabels: { 'kubernetes.io/metadata.name': namespace }, labels, ports,
|
||||
};
|
||||
}
|
||||
|
||||
export const ip = (address) => ({ kind: 'ip', owner: `ip/${address}`, ip: address });
|
||||
|
||||
export function matchesSelector(selector, labels) {
|
||||
if (!selector) return true;
|
||||
const plain = Object.entries(selector.matchLabels ?? {}).every(([k, v]) => labels[k] === v);
|
||||
const expressions = (selector.matchExpressions ?? []).every(({ key, operator, values = [] }) => {
|
||||
if (operator === 'In') return values.includes(labels[key]);
|
||||
if (operator === 'NotIn') return !values.includes(labels[key]);
|
||||
if (operator === 'Exists') return key in labels;
|
||||
if (operator === 'DoesNotExist') return !(key in labels);
|
||||
throw new Error(`unsupported selector operator ${operator}`);
|
||||
});
|
||||
return plain && expressions;
|
||||
}
|
||||
|
||||
export const isEmptySelector = (s) =>
|
||||
!s || (Object.keys(s.matchLabels ?? {}).length === 0 && (s.matchExpressions ?? []).length === 0);
|
||||
|
||||
export function policyTypes(policy) {
|
||||
return policy.spec.policyTypes ?? (policy.spec.egress ? ['Ingress', 'Egress'] : ['Ingress']);
|
||||
}
|
||||
|
||||
function parseIp(address) {
|
||||
if (address.includes(':')) {
|
||||
const [head, tail] = address.includes('::') ? address.split('::') : [address, null];
|
||||
const h = head ? head.split(':') : [];
|
||||
const t = tail ? tail.split(':') : [];
|
||||
const groups = tail === null ? h : [...h, ...Array(8 - h.length - t.length).fill('0'), ...t];
|
||||
return { bits: 128, value: groups.reduce((acc, g) => (acc << 16n) + BigInt(parseInt(g, 16)), 0n) };
|
||||
}
|
||||
return { bits: 32, value: address.split('.').reduce((acc, o) => (acc << 8n) + BigInt(Number(o)), 0n) };
|
||||
}
|
||||
|
||||
export function cidrContains(cidr, address) {
|
||||
const [network, prefix] = cidr.split('/');
|
||||
const net = parseIp(network);
|
||||
const host = parseIp(address);
|
||||
if (net.bits !== host.bits) return false;
|
||||
const shift = BigInt(net.bits - Number(prefix));
|
||||
return net.value >> shift === host.value >> shift;
|
||||
}
|
||||
|
||||
function peerMatches(peer, endpoint, policyNamespace) {
|
||||
if (peer.ipBlock) {
|
||||
return (
|
||||
endpoint.kind === 'ip' &&
|
||||
cidrContains(peer.ipBlock.cidr, endpoint.ip) &&
|
||||
!(peer.ipBlock.except ?? []).some((cidr) => cidrContains(cidr, endpoint.ip))
|
||||
);
|
||||
}
|
||||
if (endpoint.kind !== 'pod') return false;
|
||||
const namespaceOk = peer.namespaceSelector
|
||||
? matchesSelector(peer.namespaceSelector, endpoint.nsLabels)
|
||||
: endpoint.namespace === policyNamespace;
|
||||
return namespaceOk && matchesSelector(peer.podSelector, endpoint.labels);
|
||||
}
|
||||
|
||||
function portMatches(ports, port, protocol, destination) {
|
||||
if (!ports || ports.length === 0) return true;
|
||||
return ports.some((p) => {
|
||||
if ((p.protocol ?? 'TCP') !== protocol) return false;
|
||||
if (p.port === undefined) return true;
|
||||
if (typeof p.port === 'string') {
|
||||
return (destination.ports ?? []).some(
|
||||
(c) => c.name === p.port && c.containerPort === port && (c.protocol ?? 'TCP') === protocol,
|
||||
);
|
||||
}
|
||||
return p.endPort ? port >= p.port && port <= p.endPort : p.port === port;
|
||||
});
|
||||
}
|
||||
|
||||
/** Everything the tests need to reason about one rendered document set. */
|
||||
export function modelOf(docs) {
|
||||
const policies = docs.filter((d) => d.kind === 'NetworkPolicy');
|
||||
const pods = podsOf(docs);
|
||||
|
||||
const policiesFor = (pod, type) =>
|
||||
policies.filter(
|
||||
(p) =>
|
||||
(p.metadata.namespace ?? NAMESPACE) === pod.namespace &&
|
||||
matchesSelector(p.spec.podSelector, pod.labels) &&
|
||||
policyTypes(p).includes(type),
|
||||
);
|
||||
|
||||
function allows(direction, pod, peer, port, protocol) {
|
||||
const type = direction === 'egress' ? 'Egress' : 'Ingress';
|
||||
const relevant = policiesFor(pod, type);
|
||||
if (relevant.length === 0) return true;
|
||||
const destination = direction === 'egress' ? peer : pod;
|
||||
return relevant.some((policy) =>
|
||||
(policy.spec[direction] ?? []).some((rule) => {
|
||||
const peers = rule[direction === 'egress' ? 'to' : 'from'];
|
||||
const peerOk =
|
||||
!peers || peers.length === 0 ||
|
||||
peers.some((p) => peerMatches(p, peer, policy.metadata.namespace ?? NAMESPACE));
|
||||
return peerOk && portMatches(rule.ports, port, protocol, destination);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const allowsEgress = (src, dst, port, protocol = 'TCP') => allows('egress', src, dst, port, protocol);
|
||||
const allowsIngress = (dst, src, port, protocol = 'TCP') => allows('ingress', dst, src, port, protocol);
|
||||
const canConnect = (src, dst, port, protocol = 'TCP') =>
|
||||
(src.kind !== 'pod' || allowsEgress(src, dst, port, protocol)) &&
|
||||
(dst.kind !== 'pod' || allowsIngress(dst, src, port, protocol));
|
||||
|
||||
return {
|
||||
docs,
|
||||
pods,
|
||||
policies,
|
||||
policiesFor,
|
||||
allowsEgress,
|
||||
allowsIngress,
|
||||
canConnect,
|
||||
byApp: (app) => pods.filter((p) => p.labels.app === app),
|
||||
byOwner: (owner) => pods.find((p) => p.owner === owner),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Renders every rollout stage of overlays/encry (Runbook steps 1, 2, 3 and 5)
|
||||
// so manifest-policy.test.mjs can check that each intermediate state keeps
|
||||
// the application reachable. A stage is the committed overlay with only the
|
||||
// components released up to that step; the overlay in the repo is not
|
||||
// touched, each stage is rendered from a temporary copy.
|
||||
//
|
||||
// Usage: node deploy/k8s/tests/render-stages.mjs <outDir>
|
||||
// Env: KUBECTL (default "kubectl"), YQ (default "yq")
|
||||
// Output: <outDir>/<stage>.yaml per stage (for kubeconform) and
|
||||
// <outDir>/stages.json = { "<stage>": [rendered documents] }
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const K8S_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const OVERLAY_FILE = join('overlays', 'encry', 'kustomization.yaml');
|
||||
const ROLLOUT_ORDER = ['network-policies', 'pod-hardening', 'backend-roles', 'api-hpa'];
|
||||
const STAGES = {
|
||||
'step1-ist-stand': [],
|
||||
'step2-network-policies': ROLLOUT_ORDER.slice(0, 1),
|
||||
'step3-roles-and-hardening': ROLLOUT_ORDER.slice(0, 3),
|
||||
'step5-hpa': ROLLOUT_ORDER,
|
||||
};
|
||||
const COMPONENT_LINE = /^\s+-\s+\.\.\/\.\.\/components\/([\w-]+)/;
|
||||
|
||||
/** Splits the overlay into the lines before and after its components list. */
|
||||
function splitOverlay(text) {
|
||||
const lines = text.split('\n');
|
||||
const start = lines.findIndex((l) => l.startsWith('components:'));
|
||||
if (start < 0) throw new Error(`${OVERLAY_FILE}: no components list`);
|
||||
let end = start + 1;
|
||||
while (end < lines.length && COMPONENT_LINE.test(lines[end])) end += 1;
|
||||
const listed = lines.slice(start + 1, end).map((l) => l.match(COMPONENT_LINE)[1]);
|
||||
if (listed.join() !== ROLLOUT_ORDER.join()) {
|
||||
throw new Error(
|
||||
`${OVERLAY_FILE} lists components [${listed}], render-stages.mjs expects [${ROLLOUT_ORDER}]. ` +
|
||||
'Update ROLLOUT_ORDER and STAGES together with the overlay and the runbook.',
|
||||
);
|
||||
}
|
||||
return { before: lines.slice(0, start), after: lines.slice(end) };
|
||||
}
|
||||
|
||||
function overlayFor(parts, components) {
|
||||
const list = components.length === 0
|
||||
? ['components: []']
|
||||
: ['components:', ...components.map((c) => ` - ../../components/${c}`)];
|
||||
return [...parts.before, ...list, ...parts.after].join('\n');
|
||||
}
|
||||
|
||||
function renderStage(parts, components, outDir, name) {
|
||||
const tree = mkdtempSync(join(outDir, `${name}-tree-`));
|
||||
try {
|
||||
cpSync(K8S_DIR, tree, { recursive: true });
|
||||
writeFileSync(join(tree, OVERLAY_FILE), overlayFor(parts, components));
|
||||
const yaml = execFileSync(process.env.KUBECTL ?? 'kubectl', ['kustomize', join(tree, 'overlays', 'encry')], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const yamlFile = join(outDir, `${name}.yaml`);
|
||||
writeFileSync(yamlFile, yaml);
|
||||
const json = execFileSync(process.env.YQ ?? 'yq', ['ea', '-o=json', '[.]', yamlFile], { encoding: 'utf8' });
|
||||
return JSON.parse(json).filter(Boolean);
|
||||
} finally {
|
||||
rmSync(tree, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const outDir = process.argv[2] && resolve(process.argv[2]);
|
||||
if (!outDir) throw new Error('usage: node render-stages.mjs <outDir>');
|
||||
// The copy of deploy/k8s lands in outDir; inside deploy/k8s it would copy itself.
|
||||
const fromK8s = relative(K8S_DIR, outDir);
|
||||
if (!fromK8s.startsWith('..') && !isAbsolute(fromK8s)) throw new Error('outDir must be outside deploy/k8s');
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const parts = splitOverlay(readFileSync(join(K8S_DIR, OVERLAY_FILE), 'utf8'));
|
||||
const stages = Object.fromEntries(
|
||||
Object.entries(STAGES).map(([name, components]) => [name, renderStage(parts, components, outDir, name)]),
|
||||
);
|
||||
writeFileSync(join(outDir, 'stages.json'), JSON.stringify(stages));
|
||||
for (const [name, docs] of Object.entries(stages)) process.stdout.write(`${name}: ${docs.length} documents\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in new issue
Block a user