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>
This commit is contained in:
CTO Agent 2026-08-16 16:20:06 +00:00
parent 550d1276f7
commit b1680431c6
4 changed files with 195 additions and 12 deletions

View file

@ -44,7 +44,10 @@ spec:
kubernetes.io/hostname: k3s-worker-2 kubernetes.io/hostname: k3s-worker-2
containers: containers:
- name: restic - name: restic
image: restic/restic:0.17.3 image: harbor.basicstack.de/library/restic:0.17.3
# Mirrored from docker.io/restic/restic:0.17.3 (DEV-493) —
# deterministic ingress via Harbor. Retag procedure in
# docs/monitoring/restic-restore.md § "Tag-bump procedure".
env: env:
- name: AWS_ACCESS_KEY_ID - name: AWS_ACCESS_KEY_ID
valueFrom: valueFrom:

View file

@ -90,7 +90,10 @@ spec:
mountPath: /dump mountPath: /dump
containers: containers:
- name: restic - name: restic
image: restic/restic:0.17.3 image: harbor.basicstack.de/library/restic:0.17.3
# Mirrored from docker.io/restic/restic:0.17.3 (DEV-493) —
# deterministic ingress via Harbor. Retag procedure in
# docs/monitoring/restic-restore.md § "Tag-bump procedure".
env: env:
- name: AWS_ACCESS_KEY_ID - name: AWS_ACCESS_KEY_ID
valueFrom: valueFrom:

View file

@ -53,7 +53,10 @@ spec:
topologyKey: kubernetes.io/hostname topologyKey: kubernetes.io/hostname
containers: containers:
- name: restic - name: restic
image: restic/restic:0.17.3 image: harbor.basicstack.de/library/restic:0.17.3
# Mirrored from docker.io/restic/restic:0.17.3 (DEV-493) —
# deterministic ingress via Harbor. Retag procedure in
# docs/monitoring/restic-restore.md § "Tag-bump procedure".
env: env:
- name: AWS_ACCESS_KEY_ID - name: AWS_ACCESS_KEY_ID
valueFrom: valueFrom:

View file

@ -13,13 +13,14 @@ event, or (b) run the quarterly restore drill that keeps the
## Repo layout ## Repo layout
Single bucket, three repo prefixes: Single bucket, four repo prefixes:
| repo prefix | source | writer CronJob | tag | | repo prefix | source | writer CronJob | tag |
| ---------------------------------------------- | ---------------------------------- | ---------------------- | --------------- | | ---------------------------------------------- | --------------------------------------------- | ---------------------- | --------------- |
| `restic/loki` | PVC `loki-storage-encrypted` | `backup-loki-restic` | `loki` | | `restic/loki` | PVC `loki-storage-encrypted` | `backup-loki-restic` | `loki` |
| `restic/grafana` | PVC `grafana-storage` (worker-2) | `backup-grafana-restic`| `grafana` | | `restic/grafana` | PVC `grafana-storage` (worker-2) | `backup-grafana-restic`| `grafana` |
| `restic/k8s-resources` | `kubectl get -o yaml` (stdin) | `backup-k8s-resources` | `k8s-resources` | | `restic/k8s-resources` | `kubectl get -o yaml` (stdin) | `backup-k8s-resources` | `k8s-resources` |
| `restic/prometheus` | PVC `prometheus-data-encrypted` (excl. WAL) | `prometheus-backup` | `prometheus` |
Password + endpoint + bucket + AWS creds live in `SealedSecret` Password + endpoint + bucket + AWS creds live in `SealedSecret`
`monitoring-s3-backup` (namespace `monitoring`). Keys: `monitoring-s3-backup` (namespace `monitoring`). Keys:
@ -28,8 +29,7 @@ Password + endpoint + bucket + AWS creds live in `SealedSecret`
locally, mirrored to Passbolt entry `restic / monitoring backups`. locally, mirrored to Passbolt entry `restic / monitoring backups`.
- `access-key`, `secret-key` — Hetzner S3 credentials. - `access-key`, `secret-key` — Hetzner S3 credentials.
- `endpoint` — e.g. `https://hel1.your-objectstorage.com`. - `endpoint` — e.g. `https://hel1.your-objectstorage.com`.
- `bucket` — Hetzner bucket name (also used by the legacy Prometheus - `bucket` — Hetzner bucket name (all four restic repos share it).
rclone job).
Encryption is done **client-side by restic**. Hetzner Object Storage Encryption is done **client-side by restic**. Hetzner Object Storage
has no SSE-S3 / SSE-KMS ([FAQ](https://docs.hetzner.com/storage/object-storage/faq/general/)); has no SSE-S3 / SSE-KMS ([FAQ](https://docs.hetzner.com/storage/object-storage/faq/general/));
@ -70,7 +70,7 @@ spec:
restartPolicy: Never restartPolicy: Never
containers: containers:
- name: restic - name: restic
image: restic/restic:0.17.3 # matches CronJob image image: harbor.basicstack.de/library/restic:0.17.3 # matches CronJob image (DEV-493)
command: ["sleep", "3600"] command: ["sleep", "3600"]
env: env:
- { name: AWS_ACCESS_KEY_ID, valueFrom: { secretKeyRef: { name: monitoring-s3-backup, key: access-key } } } - { name: AWS_ACCESS_KEY_ID, valueFrom: { secretKeyRef: { name: monitoring-s3-backup, key: access-key } } }
@ -127,6 +127,41 @@ restic restore latest --target /work/grafana --tag grafana
Follows the loki pattern (restored tree under `/work/grafana/source/`). Follows the loki pattern (restored tree under `/work/grafana/source/`).
### Prometheus
```sh
export RESTIC_REPOSITORY="s3:${S3_ENDPOINT}/${S3_BUCKET}/restic/prometheus"
restic snapshots
restic restore latest --target /work/prometheus --tag prometheus
find /work/prometheus/source -maxdepth 1 -type d | head
```
Restored tree lands under `/work/prometheus/source/…`. **WAL and
`chunks_head/` are excluded on purpose** — see the "compaction race"
notes at the top of `apps/monitoring/prometheus-backup-cronjob.yaml`.
Expect the last ~15 s of ingested samples to be lost on recovery; the
compacted 2h/24h blocks are complete.
**Integrity check — `promtool tsdb analyze` (required for the DEV-492
drill).** The snapshot must load cleanly through Prometheus's own
verifier before we call the drill green.
```sh
kubectl -n restore-drill run promtool-check --rm -it --restart=Never \
--image=prom/prometheus:v2.53.1 \
--overrides='{"spec":{"containers":[{"name":"promtool-check","image":"prom/prometheus:v2.53.1","command":["sh","-c","for b in /work/prometheus/source/*/; do echo \"--- $b\"; promtool tsdb analyze /work/prometheus/source \"$(basename $b)\" || exit 1; done"],"volumeMounts":[{"name":"work","mountPath":"/work"}]}],"volumes":[{"name":"work","emptyDir":{}}]}}'
```
Simpler drill path (the restore pod already has restic; `promtool` is
not shipped in `restic/restic`, so run it from a `prom/prometheus`
sidecar or an interactive pod that mounts the same `emptyDir` — see
DEV-492 drill entry for the concrete two-pod recipe used on the
first run).
Exit 0 on every block = pass. A non-zero exit on any block means the
compaction-race mitigation slipped and the snapshot is corrupt.
Re-run the CronJob and re-drill.
## Integrity checks ## Integrity checks
Two layers. Two layers.
@ -198,6 +233,145 @@ restic key remove <old-id>
Rotate annually or immediately on suspected compromise. Rotate annually or immediately on suspected compromise.
## Restic image (Harbor mirror)
All three restic CronJobs (`backup-loki-restic`, `backup-grafana-restic`,
`backup-k8s-resources`) reference a Harbor-hosted copy of upstream to
keep the backup pipeline off the Docker Hub pull path and immune to
upstream retagging:
- Manifest reference: `harbor.basicstack.de/library/restic:0.17.3`
- Upstream: `docker.io/restic/restic:0.17.3`
- Ticket: [DEV-493](/DEV/issues/DEV-493)
The `library` project is public, so no `imagePullSecret` is required
on the CronJob pods.
### Tag-bump procedure
Run this when we want to move restic to a new pinned tag (e.g. 0.17.3
→ 0.17.4). Do the mirror push **before** editing manifests so Argo
cannot roll pods onto an unmirrored tag.
1. **Pick and verify the upstream tag.** Confirm the tag exists on
Docker Hub and (ideally) read the upstream restic release notes for
breaking changes:
```sh
curl -s "https://hub.docker.com/v2/repositories/restic/restic/tags/0.17.4"
```
2. **Mirror the image to Harbor** with a one-shot in-cluster crane
Job. Requires an admin (or `library`-scoped robot) Harbor
credential; the Job auth secret is throwaway.
```sh
# from the CTO workstation with kubectl + Harbor admin access
NEW_TAG=0.17.4
ADMIN_PW=$(kubectl -n harbor get secret harbor-secrets \
-o jsonpath='{.data.harborAdminPassword}' | base64 -d)
AUTH=$(printf 'admin:%s' "$ADMIN_PW" | base64 -w0)
cat >/tmp/dc.json <<JSON
{"auths":{"harbor.basicstack.de":{"auth":"$AUTH"}}}
JSON
kubectl -n harbor create secret generic crane-mirror-cfg \
--from-file=config.json=/tmp/dc.json \
--dry-run=client -o yaml | kubectl apply -f -
rm /tmp/dc.json
cat <<YAML | kubectl apply -f -
apiVersion: batch/v1
kind: Job
metadata:
name: crane-mirror-restic-${NEW_TAG//./-}
namespace: harbor
spec:
backoffLimit: 1
ttlSecondsAfterFinished: 600
template:
spec:
restartPolicy: Never
containers:
- name: crane
image: gcr.io/go-containerregistry/crane:v0.21.9
env: [{name: DOCKER_CONFIG, value: /docker}]
args:
- copy
- docker.io/restic/restic:${NEW_TAG}
- harbor.basicstack.de/library/restic:${NEW_TAG}
volumeMounts:
- {name: docker-config, mountPath: /docker, readOnly: true}
volumes:
- name: docker-config
secret: {secretName: crane-mirror-cfg}
YAML
kubectl -n harbor wait --for=condition=complete \
job/crane-mirror-restic-${NEW_TAG//./-} --timeout=5m
kubectl -n harbor logs job/crane-mirror-restic-${NEW_TAG//./-} | tail -20
```
3. **Sanity-check the artifact** — the Harbor digest must match the
digest crane just pushed, and an anonymous pull must resolve:
```sh
ADMIN_PW=$(kubectl -n harbor get secret harbor-secrets \
-o jsonpath='{.data.harborAdminPassword}' | base64 -d)
curl -sk -u "admin:$ADMIN_PW" \
"https://harbor.basicstack.de/api/v2.0/projects/library/repositories/restic/artifacts" \
| jq -r '.[] | "digest=\(.digest) tags=\((.tags//[])|map(.name)|join(","))"'
```
4. **Clean up the mirror secret and Job** (the Job also TTLs itself
in 10 min):
```sh
kubectl -n harbor delete secret crane-mirror-cfg
kubectl -n harbor delete job crane-mirror-restic-${NEW_TAG//./-} \
--ignore-not-found
```
5. **Bump the manifest.** Update the three files under `apps/monitoring/`
in the `basicstack-repo`:
- `backup-loki-restic-cronjob.yaml`
- `backup-grafana-restic-cronjob.yaml`
- `backup-k8s-resources-cronjob.yaml`
Each references `harbor.basicstack.de/library/restic:<tag>` under
the `restic` container. Bump `<tag>` in all three, commit, push.
6. **Let Argo sync** — the `monitoring` Application picks up the new
manifests. Watch for successful reconciliation:
```sh
kubectl -n argocd get application monitoring \
-o jsonpath='{.status.sync.status}{" "}{.status.health.status}{"\n"}'
```
7. **Trigger one CronJob run to prove the Harbor pull is green.**
Create a manual Job from any of the CronJobs (loki is fine) and
inspect its logs:
```sh
kubectl -n monitoring create job --from=cronjob/backup-loki-restic \
smoke-restic-$(date +%Y%m%d-%H%M%S)
```
The Pod should pull from `harbor.basicstack.de/library/restic:<tag>`
(`kubectl describe pod ...` → Events → `Pulling image …`) and
finish with `restic backup /source` output ending in a snapshot id.
8. **Update this doc's "restore pod" template** if the new tag is
incompatible with the existing restore pod command — the drill pod
image must match the CronJob image, otherwise the restore-drill
won't round-trip.
**Rollback:** if the new tag misbehaves, edit the three manifests back
to the last-known-good tag, commit, and let Argo re-sync. The old
tag's manifest remains in Harbor until it's explicitly deleted, so
rollback is a manifest change only.
## Drill log ## Drill log
Every drill appends to this section. Include: Every drill appends to this section. Include: