Commit graph

15 commits

Author SHA1 Message Date
CTO Agent
89cf3094d0 chore(monitoring): add restic alerts for forgejo repo (DEV-514)
New freshness (BackupForgejoStale, 28h) and integrity
(BackupForgejoCheckFailed) rules matching the sibling
loki/grafana/k8s-resources/prometheus alerts.

Metrics come from backup_forgejo_{timestamp_seconds,check_status,success}
emitted by apps/forgejo/forgejo-backup-cronjob.yaml.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-23 01:16:38 +00:00
CTO Agent
810d22f0a6 feat(monitoring): prometheus-backup → restic client-side encrypted (DEV-492)
Migrates the last plaintext-at-rest monitoring backup off rclone-sync
onto the same restic pipeline as loki/grafana/k8s-resources. Closes
the Hetzner Object Storage gap flagged in DEV-482 — no SSE-S3/SSE-KMS
means every object we push must be encrypted client-side.

- apps/monitoring/prometheus-backup-cronjob.yaml: pin the image to the
  Harbor mirror `harbor.basicstack.de/library/restic:0.17.3` (DEV-493)
  so the pull path matches the sibling CronJobs.
- docs/monitoring/restic-restore.md: append the DEV-492 drill entry —
  cold restic init, 8.017 GiB → 2.441 GiB stored, `restic check`
  clean, restore of `latest` into a scratch namespace, then
  `promtool tsdb list` + `promtool tsdb analyze` against all 19
  blocks (0 failed). Confirms the compaction-race mitigation
  (`--exclude wal/*`/`chunks_head/*` + accept exit 3) does not leave
  a corrupt snapshot. Also documents the DEV-492 Prometheus repo in
  the layout table + per-repo restore commands.
- apps/monitoring/README.md: point the restore section at all four
  repos and cite DEV-488 (loki/k8s) + DEV-492 (prometheus) drill
  entries.

Definition of done (DEV-492):
- backup ships client-side-encrypted to Hetzner:  (restic init
  succeeded on the manual run; snapshot 71420465 written)
- restore drill promtool tsdb analyze clean:  (19/19 blocks OK)
- OBSERVABILITY_BASELINE.md + apps/monitoring/README.md updated: 
- restic-password ownership documented:  (README shared-SealedSecret
  table already covered all four repos after this change)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-16 16:29:22 +00:00
CTO Agent
ef62dde67c feat(observability): wire node-exporter textfile collector for backup metrics (DEV-494)
Turns the four monitoring backup CronJobs' .prom output into scrapeable
Prometheus series so the DEV-490 alerts finally evaluate against live data.

- apps/observability/patches/node-exporter-textfile-collector.yaml:
  strategic-merge patch on the kube-prometheus-stack node-exporter DS
  that adds `--collector.textfile.directory=/host/textfile_collector`
  and mounts `/var/lib/node_exporter/textfile_collector` read-only.
  Chart isn't tracked in ArgoCD, so we keep the patch under version
  control and re-apply after any helm upgrade (see patches/README.md).
- apps/monitoring/backup-{loki,grafana,k8s-resources,prometheus}-*-cronjob.yaml:
  swap `emptyDir` /metrics for a `hostPath` on the same directory
  (`DirectoryOrCreate`). Write via `.prom.tmp` + `mv` so node-exporter
  never reads a truncated sample.
- apps/monitoring/backup-restic-alerts.yaml: `time() - max(...) > 28h`
  for all four freshness alerts so a stale `.prom` left on a node the
  job has since left does not fire the freshness pager.
- apps/monitoring/README.md: drop the "once wired" caveat; document
  the on-node directory, atomic write, cross-node staleness rationale.

Verified end-to-end with a synthetic `kubectl create job
--from=cronjob/backup-k8s-resources`: pod ran on k3s-worker-4,
.prom file materialized in /var/lib/node_exporter/textfile_collector,
and Prometheus returned all four metric families
(`backup_k8s_resources_success=1`, `..._timestamp_seconds`,
`..._check_status=0`, `restic_repo_size_bytes{repo="k8s-resources"}=13692516`).
`time() - max(backup_k8s_resources_timestamp_seconds)` returned ~84s
against a fresh run. `promtool check rules` + `promtool test rules`
still pass (9 rules, 5 scenarios).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-16 16:21:56 +00:00
CTO Agent
b1680431c6 feat(monitoring): pull restic image from Harbor mirror (DEV-493)
Point the three monitoring restic CronJobs at the Harbor-hosted
mirror at harbor.basicstack.de/library/restic:0.17.3 so the backup
pipeline is not gated by Docker Hub throttling or upstream retag.

- Mirrored docker.io/restic/restic:0.17.3 into the public
  `library` project via a one-shot crane copy Job. Digest in
  Harbor matches the upstream index (sha256:8f5a62b4…).
- Updated the three CronJobs
  (backup-loki-restic, backup-grafana-restic,
  backup-k8s-resources) to pull from Harbor.
- Documented the tag-bump procedure in
  docs/monitoring/restic-restore.md (§ Tag-bump procedure) and
  pointed the restore-drill pod at the same Harbor image so the
  restore round-trip stays image-consistent with the writers.

Refs: [DEV-493](/DEV/issues/DEV-493), parent [DEV-482](/DEV/issues/DEV-482).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-16 16:20:06 +00:00
CTO Agent
550d1276f7 feat(monitoring): Prometheus backup-freshness + restic alerts (DEV-490)
- New PrometheusRule apps/monitoring/backup-restic-alerts.yaml with
  seven warning-level rules:
    * BackupLokiStale / BackupGrafanaStale / BackupK8sResourcesStale
      -> time() - backup_<kind>_timestamp_seconds > 28h
    * BackupLokiCheckFailed / BackupGrafanaCheckFailed /
      BackupK8sResourcesCheckFailed -> backup_<kind>_check_status != 0
    * ResticRepoOversize -> restic_repo_size_bytes > 20 GiB (per-repo)
- Emit restic_repo_size_bytes{repo="<kind>"} from all three restic
  CronJobs (loki, grafana, k8s-resources) via `restic stats --json
  --mode raw-data` so ResticRepoOversize has data to match once the
  textfile-collector scrape path is wired.
- Companion promtool unit test backup-restic-alerts.test.yaml with
  five scenarios (fresh/stale, check pass/fail, oversize) -- verified
  locally with promtool 2.53.1: SUCCESS.
- README.md: document the three restic CronJobs, the shared
  SealedSecret keys (access-key/secret-key/endpoint/bucket/
  restic-password), the emitted textfile metrics, and the alert list.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-16 16:07:55 +00:00
CTO Agent
0c1c05fc50 feat(monitoring): retire legacy backup-volumes CronJob + backup-storage PVC (DEV-489)
The restic pipeline (loki/grafana/k8s-resources) is proven end-to-end
after the DEV-488 restore drill, so the legacy rsync/tar pipeline into
the 100 Gi local-path `backup-storage` PVC is removed.

- Delete `apps/monitoring/backup-volumes-cronjob.yaml`.
- Remove the DEV-483 bridge `nodeSelector: k3s-worker-2` from
  `apps/monitoring/loki-deployment.yaml`. Loki's data protection now
  runs via `backup-loki-restic`, which follows the pod via podAffinity
  regardless of which node the RWO CSI volume attaches on. The
  `Recreate` rollout strategy stays — it is unrelated (avoids the
  attach-deadlock during a rollout). Resolves the RWO/nodeSelector
  attach race that was blocking DEV-478 weekly OS updates.
- Update `apps/monitoring/README.md` to drop the `backup-volumes`
  section, link the restic restore runbook, and record the pin
  removal.
- Clean stale coexistence comments in the restic/prometheus CronJob
  manifests now that the legacy job is gone.

Cluster-side (already applied out-of-band, since these manifests are
`kubectl apply`-based, not Argo-managed):
- `kubectl -n monitoring delete cronjob backup-volumes` -> NotFound.
- `kubectl -n monitoring delete pvc backup-storage` -> gone; local-path
  PV `pvc-d0db0ba9-8f89-4f66-9e65-d573ebe1085a` reclaimed automatically
  (Delete policy). Two stale pre-DEV-487 `backup-k8s-resources` job
  pods that still referenced the PVC were deleted to release the
  `pvc-protection` finalizer.
- `kubectl -n monitoring apply -f loki-deployment.yaml` -> Recreate
  rollout, new pod Ready in ~60s, no nodeSelector on the new spec.
- No `VolumeAttachment` for the retired PV.
- Restic CronJobs (`backup-loki-restic`, `backup-grafana-restic`,
  `backup-k8s-resources`, `prometheus-backup`) intact.

Pre-delete snapshots retained on k3s-cp-1 under
`/root/dev489-snapshots-20260816T155411Z/` for post-mortem.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-16 15:58:43 +00:00
CTO Agent
e434fa751b feat(monitoring): backup-k8s-resources → restic --stdin to Hetzner S3 (DEV-487)
Rewrites the backup-k8s-resources CronJob to drop the local-path tarball
path and stream a YAML dump through `restic backup --stdin` into
`hetzner-s3:${BUCKET}/restic/k8s-resources`. Uses a two-container pattern:
init `alpine/k8s:1.29.4` dumps into an emptyDir, main `restic/restic:0.17.3`
reads that file on stdin. Retains `serviceAccountName: backup-sa`, drops
the `k3s-worker-2` nodeSelector (no more local-path pin), matches sibling
loki/grafana jobs on retention, `restic check --read-data-subset=5%`, and
textfile metrics (`backup_k8s_resources_success` / `_timestamp_seconds` /
`_check_status`).

Deployed in parallel with the legacy `backup-volumes` CronJob — the
`backup-storage` PVC keeps serving `backup-volumes` until DEV-482 step 6
(restore drill).

Server-side dry-run validated on the k3s control plane.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-16 15:39:06 +00:00
CTO Agent
d495a5936f feat(monitoring): backup-grafana-restic CronJob → Hetzner S3 (DEV-486)
Add a daily 03:15 UTC restic backup of the grafana-storage PVC to
s3:${S3_ENDPOINT}/${S3_BUCKET}/restic/grafana. Pinned to
k3s-worker-2 via nodeSelector because grafana-storage is a
local-path PV anchored there — no podAffinity needed. Same restic
retention as the loki sibling (7d/4w/6m + prune + 5% check) with
metrics backup_grafana_{success,timestamp_seconds,check_status}
written to the emptyDir textfile path. Deployed in parallel with
the legacy backup-volumes CronJob (Option 4 rollout, DEV-482).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-16 15:34:37 +00:00
CTO Agent
e2fd90a22b feat(monitoring): backup-loki-restic CronJob → Hetzner S3 (DEV-485)
Step 2 of the DEV-482 Option 4 rollout. Adds a restic-based Loki
backup that streams the loki-storage-encrypted PVC (mounted RO) to
s3:${S3_ENDPOINT}/${S3_BUCKET}/restic/loki, client-side encrypted by
restic. Co-schedules with the Loki pod via podAffinity so it lands on
whichever worker holds the RWO VolumeAttachment.

Verified with a manual run in-cluster:
- snapshot 98c6fee6 written, listable via a fresh restic pod
- restic check --read-data-subset=5% clean
- resource requests dropped from the plan's 200m/256Mi to 100m/128Mi
  because worker-2 has ~150m free CPU (Loki + Grafana + backup-* live
  there); limits stay generous for pack/check bursts.

Runs in parallel with the legacy backup-volumes CronJob — DEV-482
step 6 will retire that job only after the restore drill passes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-16 15:27:40 +00:00
CTO Agent
95de27437b feat(monitoring): seal restic-password into monitoring-s3-backup (DEV-484)
Add key restic-password to SealedSecret monitoring-s3-backup so the
upcoming restic CronJobs (DEV-485/486/487) can mount it as
RESTIC_PASSWORD without any further secret rotation. Value is a fresh
32-byte openssl-random base64 password, sealed strict-scope against
the running sealed-secrets-controller. Server-side dry-run of the
new SealedSecret reports "configured".

Bucket write probe against s3://basicstack-backup/ succeeded from an
in-cluster amazon/aws-cli pod using the existing access-key/secret-key
in the same SealedSecret (ls, put, stat, delete).

Recovery-key copy for Passbolt is held root-only on the control plane
at /root/dev-484/restic-password.txt pending the human-side Passbolt
paste; that step tracks as follow-up.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-16 13:22:10 +00:00
CTO Agent
890809c91c fix(monitoring): Loki uses Recreate strategy for RWO PVC rollouts (DEV-483)
RollingUpdate deadlocks the Loki rollout because loki-storage-encrypted
is a Hetzner CSI RWO volume — the new pod on worker-2 cannot attach the
PVC while the old pod on worker-1 still holds it. Recreate drains the
old pod first so the CSI detaches the volume cleanly.

Same pattern as the Harbor fix (commit 56e598f).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-16 12:54:26 +00:00
CTO Agent
e4a7e5629a fix(monitoring): pin Loki to k3s-worker-2 to unblock backup-volumes (DEV-483)
Bridge fix so DEV-478 weekly OS-update preflight can proceed while
DEV-482 (Option 4: restic -> Hetzner Object Storage) is designed and
rolled out. Captures the previously out-of-band Loki Deployment as a
manifest and adds nodeSelector kubernetes.io/hostname: k3s-worker-2 so
the loki-storage-encrypted RWO CSI volume co-locates with the
backup-volumes CronJob on worker-2, resolving the RWO/nodeSelector race.

This nodeSelector is temporary. It comes back out as part of DEV-482
step 6 when the legacy backup-volumes CronJob is retired.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-16 12:50:22 +00:00
CTO Agent
a5ceedf4da fix(monitoring): prune Prometheus S3 backups by prefix-date, not file mtime (DEV-465)
The original `rclone delete --min-age 7d` step ate the freshly uploaded
backup: rclone preserves each source file's mtime, and Prometheus TSDB
chunk/block files retain very old mtimes (weeks-old immutable blocks),
so filtering by file age deleted ~72% of the objects immediately after
sync (verified: 8.764 GiB destination reduced to 2.310 GiB / 37 objects
before we noticed).

Replaced the mtime prune with a prefix-name-based prune: every top-level
prefix is `prometheus-YYYYMMDD-HHMMSS`, so we parse the encoded date and
`rclone purge` whole prefixes older than 7 days. This keeps the latest
7 daily snapshots intact regardless of the Prometheus block ages.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-09 17:40:50 +00:00
CTO Agent
63aa116ef1 Add dedicated Prometheus data backup CronJob (DEV-465)
After the DEV-464 split, `monitoring/backup-volumes` only backs up
grafana + loki (pinned to k3s-worker-2 with `backup-storage`), leaving
Prometheus data unbacked. `prometheus-data-encrypted` is an RWO Hetzner
Cloud volume attached to whichever node currently runs the Prometheus
pod (typically k3s-worker-1), so it cannot join the shared backup-volumes
job without provoking Multi-Attach errors.

This introduces a dedicated `monitoring/prometheus-backup` CronJob that:

- Streams `prometheus-data-encrypted` to Hetzner S3 via rclone
  (`basicstack-backup/prometheus/prometheus-<DATE>/`).
- Uses `podAffinity` to co-schedule with the Prometheus pod so the RWO
  PVC always attaches on the same node.
- Runs at 03:30 daily, `Forbid` concurrency, 60m hard deadline.
- Retains 7 days of dated backups (rclone delete --min-age 7d).
- Tolerates the expected TSDB compaction race (Prometheus deletes old
  block dirs mid-copy): rclone's non-zero exit from those transient
  errors is captured, then success is validated by comparing dest
  bytes to source bytes (>= 80% and > 100 MiB floor).

S3 credentials are the same Hetzner Object Storage account used by
`opencloud-backup` and `stalwart-backup`, resealed for the `monitoring`
namespace as `SealedSecret monitoring-s3-backup`.

Verified with a manual job on k3s-worker-1 (see DEV-465 for logs).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-09 17:18:21 +00:00
CTO Agent
9d1996051e fix(backups): Repair three broken CronJobs blocking weekly OS updates (DEV-464)
- Create the missing forgejo/platform-backup-data PVC that forgejo-backup
  references (20Gi hcloud-volumes-encrypted).
- Record monitoring/backup-k8s-resources with a k3s-worker-2 nodeSelector
  (backup-storage PVC is local-path pinned there), lower memory request
  (128Mi) so it fits worker-2 pressure, and switch to alpine/k8s image
  (bitnami/kubectl is no longer resolvable).
- Rewrite monitoring/backup-volumes to only back up grafana + loki
  co-located with backup-storage on k3s-worker-2. Prometheus data
  lives on k3s-worker-1 and is intentionally excluded here; a
  dedicated Prometheus data backup follows in a separate ticket.

The three CronJobs previously left Pending/ContainerCreating pods that
blocked the OS-update health guard in DEV-463.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-09 16:51:30 +00:00