From 5d8d3c4348cda110cbc4ccce8cfbc207e7df6c39 Mon Sep 17 00:00:00 2001 From: Mikko Numminen Date: Fri, 24 Jul 2026 09:15:06 +0300 Subject: [PATCH 1/3] ci: cancel superseded runs on the same ref Same rationale as the mutation workflow (b70e515): a newer push always re-validates, so finishing the older ~5-minute run only burns a runner. Today's nine-PR session left a trail of completed runs for superseded pushes. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From 547a90a757b9c9107ba0f193d9ce1b73367957c2 Mon Sep 17 00:00:00 2001 From: Mikko Numminen Date: Fri, 24 Jul 2026 09:15:08 +0300 Subject: [PATCH 2/3] ci(mutation): drop the pre-v5 env names from the Stryker step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MONGODB_URI is read by nothing (server tests use mongodb-memory-server), NEXTAUTH_URL was the legacy URL name, and NEXTAUTH_SECRET only mattered as totpCrypto's legacy fallback — give it the dedicated TOTP_ENCRYPTION_KEY name instead. Validated by running the Stryker-scoped suites (totpCrypto, twoFactor actions, auditHashChain — 74 tests) with all three removed vars explicitly unset: green. Co-Authored-By: Claude Fable 5 --- .github/workflows/mutation.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 From dd2f3e006016c194763c1ae6f78dbd72e66d165e Mon Sep 17 00:00:00 2001 From: Mikko Numminen Date: Fri, 24 Jul 2026 09:15:10 +0300 Subject: [PATCH 3/3] feat(k8s): schedule the cron endpoints with CronJobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit-outbox drain and pg-boss job drain only ran on Vercel (vercel.json crons) — a k8s deployment had no scheduler at all, so the outbox backlog and job queue would sit undrained. Add the k8s-native mirror: two CronJobs (raw manifests; Helm behind cron.enabled, off by default until CRON_SECRET is set), POSTing with the Bearer secret to the same routes. CRON_SECRET moves from commented-optional to a real key in the raw secret template since cronjob.yaml consumes it. Co-Authored-By: Claude Fable 5 --- k8s/README.md | 11 +++ k8s/helm/hrmanager/templates/cronjob.yaml | 44 ++++++++++++ k8s/helm/hrmanager/values.yaml | 15 ++++- k8s/manifests/cronjob.yaml | 82 +++++++++++++++++++++++ k8s/manifests/secret.yaml | 6 +- 5 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 k8s/helm/hrmanager/templates/cronjob.yaml create mode 100644 k8s/manifests/cronjob.yaml 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"