workspace-mail: mail-backup can write (source untouched), rejects empty backups, + never-succeeded CronJob alert - #1120
Conversation
…sGroup The mail-backup job runs as uid 1000 but the workspace-mail-backup PVC is a freshly provisioned PD (root:root 0755), so the backup container gets EACCES creating the tarball. Fix the write permission with an initContainer that chowns ONLY the destination mount to 1000:1000 (CAP_CHOWN, root for that one step). Deliberately NOT a pod-level fsGroup: fsGroup applies to every mounted volume, so it would also rewrite the read-only mail SOURCE volume's root group to 1000 -- and OnRootMismatch re-applies after a restore, fighting the lane restoring /var/mail/vhosts. The initContainer never mounts the source, so the source ownership is provably untouched. Also correct the image comment: the pre-change probe verified the PULL only; its 'produces a real, listable .tar.gz' claim was against a writable scratch dir, not the real backup PVC (which EACCESes on a fresh PD).
The maildir is currently empty, so the verified backup was 130 bytes -- a readable, listable .tar.gz of nothing. #1114's tar-tzf verify passes on it, so a green run proves nothing. Add a byte-precise non-triviality gate (MIN_BACKUP_BYTES, default 1024): below the floor the job fails and removes the trivial archive instead of receipting it as a success. Also archive with 'tar -C /maildata --exclude=./lost+found .' so the job skips ext4's root:root 0700 lost+found. The write no longer needs group access to the mount root to READ the source; only the destination needs the initContainer chown to WRITE.
Nothing watched for a CronJob that stopped succeeding. The canonical 'time() - kube_cronjob_status_last_successful_time > N' is silent for a job that NEVER succeeded: the metric has no series for it, so the never-run jobs (mail-backup, minio-sync -- both failing nightly, unalerted) are exactly the ones it cannot see. Add cronjob-staleness with the second 'unless' arm that emits a marker for every scheduled-but-never-succeeded job, intersected with spec_suspend==0. Delivery: the alert keeps its metric namespace (socioprophet for mail-backup). #1112's alert-sink AlertmanagerConfig is OnNamespace=observability only, so until #1112's default-route change syncs this fires into the null sink. Noted in-file; not relabelled into observability (that would misattribute the job).
…ses real Runs the SHIPPED script (extracted from kubectl kustomize, mount paths localized) against a synthesized empty maildir and a populated one. Proves an empty maildir tars to a sub-1KB archive that fails the gate (exit 1, archive removed, receipt status=failed) while a real maildir passes (exit 0, retained), and that lost+found is excluded. Local-only; Actions are spend-capped.
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Makes the mail-backup CronJob durable in GitOps by ensuring it can write to its backup PVC without mutating the mail source volume, failing on trivially small “empty” backups, and adding an alert for CronJobs that stop succeeding (including those that never succeeded).
Changes:
- Add an initContainer that chowns only the backup destination mount so the unprivileged backup container can write without touching source volume ownership.
- Update backup creation to exclude
lost+foundand add a minimum-bytes gate that fails and deletes trivially small archives. - Add a PrometheusRule to alert on CronJobs that haven’t succeeded recently or have never succeeded, plus a local test script validating the size gate.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| infra/k8s/workspace-mail/base/backup-cronjob.yaml | Adds initContainer permissions fix and non-trivial backup gate; tweaks tar invocation and comments. |
| infra/k8s/workspace-mail/tests/backup-nontrivial.test.sh | Adds a local-only regression test that renders the manifest and executes the shipped script against fixtures. |
| infra/k8s/observability/base/prometheusrule-cronjob-staleness.yaml | Adds a CronJob staleness alert that handles “never succeeded” CronJobs. |
| infra/k8s/observability/base/kustomization.yaml | Wires the new PrometheusRule into the observability base. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # (/maildata, /backup) to temp dirs so it can run unprivileged off-cluster; the | ||
| # tar invocation, the tar-tzf verify and the wc -c size gate are the real ones. | ||
| # | ||
| # Local-only (Actions are spend-capped). Requires: kubectl, python3, tar. |
| python3 - "$WORK/rendered.yaml" "$WORK/script.sh" "$WORK/env.sh" <<'PY' | ||
| import sys, yaml |
| with open(env_out, "w") as f: | ||
| for e in c["env"]: | ||
| f.write('export %s=%s\n' % (e["name"], e.get("value", ""))) |
| # workspace-mail-backup. A real archive is produced only after the | ||
| # init-backup-perms container below chowns the destination, and the size gate in | ||
| # the command rejects the trivially small (empty-maildir) case. |
|
Merge-gate disposition (Copilot 3-channel + adversarial pass) Size gate verified: step 5b sets Acknowledged, non-blocking: the test imports Verdict: MERGE-READY, but stack-gated — base is |
What this makes durable
Three fixes a cluster lane verified live for the
mail-backupCronJob. ArgoCD runsselfHeal: true, so the livekubectlpatches get reverted — this lands them in git.Stacked on #1114 (
feat/storage-retention), notmain. #1114 rewrites this exactcommand block (retention +
tar tzfverify + receipt). Rebuilding the fix beside it onmainwould produce a third divergentbackup-cronjob.yaml. Stacking makes the changestrictly additive on #1114 instead (see Overlap). Do not merge before #1114.
1. Backup can't write — fixed WITHOUT touching the mail source
mail-backupruns as uid 1000;workspace-mail-backupis a fresh PD (root:root 0755),so the container gets
EACCEScreating the tarball.The obvious fix — a pod-level
fsGroup: 1000— is wrong here.fsGroupapplies toevery mounted volume, including the read-only mail source, and rewrites its root
group to
1000(this is what dirtied/var/mail/vhoststoroot:1000 2775during liveverification).
fsGroupChangePolicy: OnRootMismatchdoes not save you: it re-applieson the next run after the ownership is restored, so it would fight the parallel lane
restoring
/var/mail/vhostsand re-dirty the source every night.Instead: an
init-backup-permsinitContainer (root,CAP_CHOWNonly) chownsonly the destination mount. The mail source is never mounted into it, so the source
volume's ownership is provably untouched by this job. Verified in the rendered manifest:
the initContainer's only
volumeMountisbackup-target.Also corrected the image comment (from
dc7344cd): its "produces a real, listable.tar.gz" claim could not have run against the real backup PVC (it
EACCESes on a freshPD). Reworded to "pull probe only", not deleted.
2. Skip
lost+found, and reject an empty backup as a FAILUREtar -C /maildata --exclude=./lost+found .— skips ext4'sroot:root 0700lost+found,so the job never needs group access to the mount root just to traverse it. The read side
no longer leans on volume ownership; only the destination needs the chown to write.
MIN_BACKUP_BYTES, default1024): the maildir is empty, so theverified artifact was 130 bytes — a readable
.tar.gzof nothing that storage: three backup jobs that delete nothing, two of which never ran #1114'star tzfverify passes happily. The gate makes a trivially small archive a failure(
rmit, receiptstatus=failed, exit 1), closing the "check that cannot fail" here too.3.
PrometheusRulefor CronJobs that never succeedinfra/k8s/observability/base/prometheusrule-cronjob-staleness.yaml. Nothing watched for aCronJob that stopped succeeding, and the canonical rule is silently wrong:
time() - kube_cronjob_status_last_successful_time > Nreturns zero series for a jobthat never succeeded (the metric has no series for it) — so the broken-since-creation jobs
(
mail-backup,minio-sync) are exactly the ones it can't see. The shipped expression addsthe
unlessarm that emits a marker for every scheduled-but-never-succeeded job,andedwith
spec_suspend == 0.Routing — reaches the null sink until #1112 (stated plainly)
The alert keeps its metric namespace (
namespace=socioprophetformail-backup). #1112'salert-sinkAlertmanagerConfig is operator-scopedOnNamespaceand matchesnamespace=observabilityonly; estate-wide delivery forsocioprophet/depends on#1112's Alertmanager default-route change (
deploy/argocd/observability-services.yaml),still
nulluntil it syncs. So this rule is live in Prometheus immediately but deliveredto no human until #1112 lands. It is intentionally not relabelled into
observability(that would misattribute a
socioprophetCronJob just to borrow #1112's sub-route).Referencing #1112's routing, not forking it — I did not touch any
alert-delivery/**file.Overlap (
merge-treevs the live lanes)My 4-file delta intersects the live lanes on exactly one file:
infra/k8s/workspace-mail/base/backup-cronjob.yamlonlyResolved by stacking:
git diff feat/storage-retention..HEADis additive (the onlyin-place edits are the corrected comment and adding
--excludeto #1114'starline;its retention/verify/receipt are untouched). Proof:
merge-treeof an equivalent main-based edit × storage: three backup jobs that delete nothing, two of which never ran #1114 → CONFLICT inbackup-cronjob.yaml(the third-divergent-block outcome, avoided).merge-treeof this branch × storage: three backup jobs that delete nothing, two of which never ran #1114 → clean (descendant).Proof (local only — Actions are spend-capped, so no CI run here)
kubectl kustomizerenders clean for all five affected kustomizations(workspace-mail base + p0-lab + p1-single-site; observability base + p0-lab).
representative kube-state-metrics series: naive form returns only the once-succeeded stale
job (blind to
mail-backup/minio-sync); shipped form returnsmail-backupandminio-sync, suppressing suspended + healthy jobs. Corroborates the live-Prometheus laneconfirmation.
infra/k8s/workspace-mail/tests/backup-nontrivial.test.shruns theshipped script (extracted from
kubectl kustomize, mount paths localized) againstsynthesized fixtures: empty maildir → 215-byte archive → exit 1, archive removed,
receipt
status=failed; populated maildir → 6755-byte archive → exit 0, retained,lost+foundexcluded, mail present. 9/9.Deviations / notes
fsGroup: 1000+fsGroupChangePolicy: OnRootMismatch. Replaced withthe initContainer per the source-untouched requirement (fsGroup can't be scoped to one
volume). Same intent ("the backup can't write without it"), without dirtying the source.
origin/main— the only way to ship the size gate as code withouta third divergent command block.
chownrestore (separate flagged decision);no autoscaling/HPA/PDB; no edits to
alert-delivery/**(observability: Alertmanager's only receiver was named "null" #1112) orinfra/k8s/zot/**(registry: bound zot storage growth and lift the 20Gi lab cap off the estate registry #1091).Do not merge.