diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 812df59..5dfa69b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,14 @@ on: pull_request: branches: [main] +# Supersede in-flight runs on the same ref: a newer push always re-validates, +# so finishing the older ~5-minute run only burns a runner (same rationale as +# mutation.yml). On main this cancels runs for already-superseded merges too — +# the newest run still validates the final state. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: ci: runs-on: ubuntu-latest diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 91fc7da..0ea5e56 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -81,10 +81,11 @@ jobs: id: stryker run: npx stryker run env: - # DATABASE_URL comes from the job-level env. - MONGODB_URI: mongodb://localhost:27017/hrmanager_audit_test - NEXTAUTH_SECRET: test-secret-for-ci - NEXTAUTH_URL: http://localhost:3000 + # DATABASE_URL comes from the job-level env. Mongo needs no URL here — + # server tests use mongodb-memory-server. TOTP_ENCRYPTION_KEY keys + # totpCrypto for related tests that don't set their own (this was + # previously the legacy NEXTAUTH_SECRET fallback name). + TOTP_ENCRYPTION_KEY: test-secret-for-ci NODE_ENV: test continue-on-error: true diff --git a/k8s/README.md b/k8s/README.md index 10099d6..b25a97c 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -232,6 +232,17 @@ secrets: --- +## Scheduled work + +Background maintenance runs through the CRON_SECRET-gated `/api/cron/*` routes, +mirroring the Vercel deployment's `vercel.json` crons: + +- **Raw manifests** — `cronjob.yaml` ships two CronJobs (audit-outbox drain at + 03:00, pg-boss job drain at 03:30). They read `CRON_SECRET` from + `hrmanager-secret`, so set that key before applying. +- **Helm** — disabled by default. Set `secrets.data.CRON_SECRET`, then deploy + with `--set cron.enabled=true` (schedules and paths under `cron.jobs`). + ## Architecture notes | Resource | Details | diff --git a/k8s/helm/hrmanager/templates/cronjob.yaml b/k8s/helm/hrmanager/templates/cronjob.yaml new file mode 100644 index 0000000..cd2d1c7 --- /dev/null +++ b/k8s/helm/hrmanager/templates/cronjob.yaml @@ -0,0 +1,44 @@ +{{- if .Values.cron.enabled }} +{{- range .Values.cron.jobs }} +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "hrmanager.fullname" $ }}-cron-{{ .name }} + namespace: {{ $.Release.Namespace }} + labels: + {{- include "hrmanager.labels" $ | nindent 4 }} + app.kubernetes.io/component: cron +spec: + schedule: {{ .schedule | quote }} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 600 + backoffLimit: 1 + template: + spec: + restartPolicy: Never + containers: + - name: curl + image: {{ $.Values.cron.image }} + env: + - name: CRON_SECRET + valueFrom: + secretKeyRef: + name: {{ include "hrmanager.secretName" $ }} + key: CRON_SECRET + # The route accepts GET or POST with the same Bearer secret. + args: + - -fsS + - --max-time + - "300" + - -X + - POST + - -H + - "Authorization: Bearer $(CRON_SECRET)" + - http://{{ include "hrmanager.fullname" $ }}-svc:{{ $.Values.service.port }}{{ .path }} +{{- end }} +{{- end }} diff --git a/k8s/helm/hrmanager/values.yaml b/k8s/helm/hrmanager/values.yaml index 5cddd83..c54cfa7 100644 --- a/k8s/helm/hrmanager/values.yaml +++ b/k8s/helm/hrmanager/values.yaml @@ -137,9 +137,22 @@ secrets: AUTH_GITHUB_SECRET: "" # The app reads MONGODB_URL — with any other name the audit trail no-ops. MONGODB_URL: "" - # Optional: gates /api/cron/* if scheduled via CronJob/external scheduler. + # Required when cron.enabled=true (gates /api/cron/*). # CRON_SECRET: "" +# -- Scheduled work (mirrors vercel.json's crons). Disabled by default because +# the endpoints 500/401 without secrets.data.CRON_SECRET — set that, then enable. +cron: + enabled: false + image: curlimages/curl:8.10.1 + jobs: + - name: audit-drain + path: /api/cron/audit-drain + schedule: "0 3 * * *" + - name: jobs-drain + path: /api/cron/jobs + schedule: "30 3 * * *" + # -- Liveness probe configuration livenessProbe: httpGet: diff --git a/k8s/manifests/cronjob.yaml b/k8s/manifests/cronjob.yaml new file mode 100644 index 0000000..1095f21 --- /dev/null +++ b/k8s/manifests/cronjob.yaml @@ -0,0 +1,82 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: hrmanager-cron-audit-drain + namespace: hrmanager + labels: + app.kubernetes.io/name: hrmanager + app.kubernetes.io/component: cron + app.kubernetes.io/managed-by: kubectl +spec: + # Mirrors vercel.json: durability backstop for the opportunistic after() drain. + schedule: "0 3 * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 600 + backoffLimit: 1 + template: + spec: + restartPolicy: Never + containers: + - name: curl + image: curlimages/curl:8.10.1 + env: + - name: CRON_SECRET + valueFrom: + secretKeyRef: + name: hrmanager-secret + key: CRON_SECRET + # The route accepts GET or POST with the same Bearer secret. + args: + - -fsS + - --max-time + - "300" + - -X + - POST + - -H + - "Authorization: Bearer $(CRON_SECRET)" + - http://hrmanager-svc:3000/api/cron/audit-drain +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: hrmanager-cron-jobs-drain + namespace: hrmanager + labels: + app.kubernetes.io/name: hrmanager + app.kubernetes.io/component: cron + app.kubernetes.io/managed-by: kubectl +spec: + # Mirrors vercel.json: drains the pg-boss queue (cleanup, audit exports). + schedule: "30 3 * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 600 + backoffLimit: 1 + template: + spec: + restartPolicy: Never + containers: + - name: curl + image: curlimages/curl:8.10.1 + env: + - name: CRON_SECRET + valueFrom: + secretKeyRef: + name: hrmanager-secret + key: CRON_SECRET + args: + - -fsS + - --max-time + - "300" + - -X + - POST + - -H + - "Authorization: Bearer $(CRON_SECRET)" + - http://hrmanager-svc:3000/api/cron/jobs diff --git a/k8s/manifests/secret.yaml b/k8s/manifests/secret.yaml index bd9fe67..48a9876 100644 --- a/k8s/manifests/secret.yaml +++ b/k8s/manifests/secret.yaml @@ -49,6 +49,6 @@ data: # (with any other name the audit trail silently no-ops). # Replace with: echo -n 'mongodb://user:password@host:27017/hrmanager_audit' | base64 MONGODB_URL: "cmVwbGFjZV93aXRoX2Jhc2U2NF9lbmNvZGVkX3ZhbHVl" - # Optional: gates the /api/cron/* endpoints (audit drain, job drain, cleanup) - # if you schedule them with a CronJob or an external scheduler. - # CRON_SECRET: "cmVwbGFjZV93aXRoX2Jhc2U2NF9lbmNvZGVkX3ZhbHVl" + # Gates the /api/cron/* endpoints — required by the CronJobs in cronjob.yaml + # (audit drain + job drain). Generate with: openssl rand -base64 32 + CRON_SECRET: "cmVwbGFjZV93aXRoX2Jhc2U2NF9lbmNvZGVkX3ZhbHVl"