Container Usage¶
How to drive HASteward as a one-shot container against a Kubernetes cluster — the form you use
when the binary isn't on $PATH (e.g. operating a remote cluster from a workstation). For the bare
hasteward <cmd> form (binary on PATH / in-cluster), see Examples.
Every command below is a complete, copy-pasteable docker run — nothing is hidden behind an
alias. Replace the <PLACEHOLDERS>. (If you run these a lot, there's an optional shortcut at the
bottom.)
Image:
docker.io/prplanit/hasteward:latest(latest= release,latest-dev= dev).
Prerequisites¶
dockeron your machine, and a workingkubectl(a kubeconfig at~/.kube/configthat reaches the cluster). The container reuses that kubeconfig.--network hostmakes the container reach the kube API exactly as yourkubectldoes.
| Placeholder | Meaning | Example |
|---|---|---|
<ENGINE> |
database engine | cnpg (PostgreSQL/CloudNativePG) or galera (MariaDB) |
<CLUSTER> |
cluster CR name | zitadel-postgres |
<NAMESPACE> |
its namespace | zeldas-lullaby |
<N> |
instance number to repair | 3 |
There are two shapes: read-only commands need only your kubeconfig; commands that take a backup
(repair, backup, restore, export, prune backups, get backups) also need a restic repo +
password + a writable temp.
Read-only commands (kubeconfig only)¶
Triage — diagnose a cluster (ALWAYS run this first)¶
docker run --rm --network host \
-e KUBECONFIG=/kube/config \
-v "$HOME/.kube:/kube:ro" \
docker.io/prplanit/hasteward:latest \
triage -e <ENGINE> -c <CLUSTER> -n <NAMESPACE>
Concrete:
docker run --rm --network host \
-e KUBECONFIG=/kube/config \
-v "$HOME/.kube:/kube:ro" \
docker.io/prplanit/hasteward:latest \
triage -e cnpg -c zitadel-postgres -n zeldas-lullaby --output json
Status of all managed clusters¶
docker run --rm --network host \
-e KUBECONFIG=/kube/config \
-v "$HOME/.kube:/kube:ro" \
docker.io/prplanit/hasteward:latest \
get status -e <ENGINE> -n <NAMESPACE>
Emergency WAL prune (CNPG disk-full deadlock — no backup needed)¶
docker run --rm --network host \
-e KUBECONFIG=/kube/config \
-v "$HOME/.kube:/kube:ro" \
docker.io/prplanit/hasteward:latest \
prune wal -e cnpg -c <CLUSTER> -n <NAMESPACE>
Backup-bearing commands (kubeconfig + restic repo)¶
These add four things to every docker run:
-e RESTIC_PASSWORD="$RESTIC_PASSWORD" # repo encryption password
-v "$HOME/hasteward-escrow:/backups" # host dir holding the restic repo
--tmpfs /tmp:size=4g # writable, ephemeral temp for restic packs
-e RESTIC_CACHE_DIR=/backups/.restic-cache # persistent restic index cache (on the host dir)
-e TMPDIR=/tmp
First-time setup of the repo + password (once per machine):
mkdir -p "$HOME/hasteward-escrow"
openssl rand -hex 16 > "$HOME/hasteward-escrow/.restic-password"
chmod 600 "$HOME/hasteward-escrow/.restic-password"
export RESTIC_PASSWORD=$(cat "$HOME/hasteward-escrow/.restic-password")
Repair ONE diverged/unhealthy instance (heal = re-clone from the primary)¶
Preview first with --dry-run, then drop it for the real run:
docker run --rm --network host \
-e KUBECONFIG=/kube/config \
-v "$HOME/.kube:/kube:ro" \
-e RESTIC_PASSWORD="$RESTIC_PASSWORD" \
-v "$HOME/hasteward-escrow:/backups" \
--tmpfs /tmp:size=4g \
-e RESTIC_CACHE_DIR=/backups/.restic-cache \
-e TMPDIR=/tmp \
docker.io/prplanit/hasteward:latest \
repair -e <ENGINE> -c <CLUSTER> -n <NAMESPACE> \
--instance <N> --backups-path /backups --output jsonl --verbose --dry-run
Add --donor <N> to force the authoritative source, --force for ambiguous Galera split-brain, or
--no-escrow to skip the pre-repair backup (only if you already have a recent one).
Repair ALL unhealthy instances at once¶
docker run --rm --network host \
-e KUBECONFIG=/kube/config \
-v "$HOME/.kube:/kube:ro" \
-e RESTIC_PASSWORD="$RESTIC_PASSWORD" \
-v "$HOME/hasteward-escrow:/backups" \
--tmpfs /tmp:size=4g \
-e RESTIC_CACHE_DIR=/backups/.restic-cache \
-e TMPDIR=/tmp \
docker.io/prplanit/hasteward:latest \
repair -e <ENGINE> -c <CLUSTER> -n <NAMESPACE> --backups-path /backups --output jsonl
Backup / list / restore / export / prune¶
These are the same docker run block as the repair example above — only the final
hasteward … line changes. Here is the full command (listing snapshots):
docker run --rm --network host \
-e KUBECONFIG=/kube/config \
-v "$HOME/.kube:/kube:ro" \
-e RESTIC_PASSWORD="$RESTIC_PASSWORD" \
-v "$HOME/hasteward-escrow:/backups" \
--tmpfs /tmp:size=4g \
-e RESTIC_CACHE_DIR=/backups/.restic-cache \
-e TMPDIR=/tmp \
docker.io/prplanit/hasteward:latest \
get backups -e cnpg -c zitadel-postgres -n zeldas-lullaby --backups-path /backups
For the others, keep everything above identical and replace only that last line with one of:
backup -e <ENGINE> -c <CLUSTER> -n <NAMESPACE> --backups-path /backups # take a backup (-m native for engine-native)
get backups -e <ENGINE> -c <CLUSTER> -n <NAMESPACE> --backups-path /backups # list snapshots
restore -e <ENGINE> -c <CLUSTER> -n <NAMESPACE> --backups-path /backups --snapshot latest
export -e <ENGINE> -c <CLUSTER> -n <NAMESPACE> --backups-path /backups --snapshot <ID> # → .sql.gz in your host dir
prune backups -e <ENGINE> -c <CLUSTER> -n <NAMESPACE> --backups-path /backups # apply retention
Worked example — healing zitadel-postgres end to end¶
Symptom: cluster phase Not enough disk space; zitadel-postgres-1 CrashLoopBackOff, -3 not
ready (only the primary -2 ready). The replicas diverged onto an older timeline after a failover and
can't catch up via streaming — they need a pg_basebackup re-clone.
# 1. Triage (read-only) — confirm the picture.
docker run --rm --network host \
-e KUBECONFIG=/kube/config -v "$HOME/.kube:/kube:ro" \
docker.io/prplanit/hasteward:latest \
triage -e cnpg -c zitadel-postgres -n zeldas-lullaby --output json
# → primary -2 healthy (timeline 49); -1 & -3 needHeal (timeline 48); safeToHeal: true
# 2. One-time: create the escrow repo + password (this cluster had no backups).
mkdir -p "$HOME/hasteward-escrow"
openssl rand -hex 16 > "$HOME/hasteward-escrow/.restic-password"
chmod 600 "$HOME/hasteward-escrow/.restic-password"
export RESTIC_PASSWORD=$(cat "$HOME/hasteward-escrow/.restic-password")
# 3. Heal a diverged replica (repeat with --instance 1, then 3, etc.).
# Add --dry-run first to preview, then run without it.
docker run --rm --network host \
-e KUBECONFIG=/kube/config -v "$HOME/.kube:/kube:ro" \
-e RESTIC_PASSWORD="$RESTIC_PASSWORD" \
-v "$HOME/hasteward-escrow:/backups" \
--tmpfs /tmp:size=4g -e RESTIC_CACHE_DIR=/backups/.restic-cache -e TMPDIR=/tmp \
docker.io/prplanit/hasteward:latest \
repair -e cnpg -c zitadel-postgres -n zeldas-lullaby \
--instance 3 --backups-path /backups --output jsonl --verbose
# 4. Verify.
kubectl get cluster zitadel-postgres -n zeldas-lullaby # → READY 3/3, "Cluster in healthy state"
The repair phases stream as jsonl: assess → safety-gate → escrow → plan → heal. The escrow
(pg_dumpall | restic backup --stdin) runs before any destructive step, and the primary is only
the pg_basebackup source — it is never modified — so the application stays up throughout.
Tip: re-run triage between heals. An instance can recover on its own (e.g. once disk frees up), so you may find you only need to heal the ones still on the old timeline.
Running as an in-cluster Kubernetes Job¶
For scheduled or production use, run HASteward inside the cluster as a Job rather than from a
workstation. It's cleaner: the Job mounts an emptyDir at /tmp (restic's temp packs just work — no
--tmpfs needed), a PVC at /backups (a durable in-cluster restic repo), and uses a ServiceAccount
with least-privilege RBAC — no kubeconfig to mount, no host networking.
The manifest is committed at docs/deploy/k8s/job/job.yaml — ServiceAccount + ClusterRole(Binding)
(cluster-wide, so it can operate on DB clusters in any namespace) + a hasteward-backups PVC + the
hasteward-run Job.
# 1. One-time: RBAC + ServiceAccount + the backups PVC.
kubectl apply -f docs/deploy/k8s/job/job.yaml -l app.kubernetes.io/component=rbac
kubectl apply -f docs/deploy/k8s/job/job.yaml -l app.kubernetes.io/component=storage
# 2. One-time: the restic password secret the Job reads.
kubectl create secret generic hasteward-restic \
--from-literal=password="$(openssl rand -hex 16)" -n <JOB_NAMESPACE>
# 3. Per run: edit the Job's `args:` + env in job.yaml, then (re)apply and watch.
# args: ["triage"] → ["repair","-i","3","--output","jsonl"]
# env: HASTEWARD_ENGINE / HASTEWARD_CLUSTER / HASTEWARD_NAMESPACE
kubectl delete job hasteward-run -n <JOB_NAMESPACE> --ignore-not-found
kubectl apply -f docs/deploy/k8s/job/job.yaml -l app.kubernetes.io/component=job
kubectl logs -f job/hasteward-run -n <JOB_NAMESPACE>
The Job passes the same knobs as the CLI, via env: HASTEWARD_ENGINE (-e), HASTEWARD_CLUSTER
(-c), HASTEWARD_NAMESPACE (-n), HASTEWARD_BACKUPS_PATH (--backups-path), and
RESTIC_PASSWORD (from the hasteward-restic secret); args: is the subcommand + flags. So
args: ["repair","-i","3"] is the in-cluster equivalent of docker run … repair -e cnpg -c … -n … -i 3.
Prefer this for scheduled backups/triage and production repairs: the escrow tempdir/cache "just works" (emptyDir
/tmp, PVC/backups), and the RBAC is least-privilege perdocs/security.md. For one-off interactive ops from your laptop, thedocker runform above is quicker.
Notes¶
- Order of operations:
triage→--dry-run→ real run.triage/get *only read;repair,backup,restore,prune *,bootstrapmutate. - Output formats:
--output jsonlstreams per-phase events — prefer it for long ops; the defaulthumanformatter buffers and can look hung while probing an unreachable instance.--output jsonis a single machine-readable envelope. - Why
--tmpfs /tmp+RESTIC_CACHE_DIRare load-bearing (and not optional): the escrow streamspg_dumpall | restic backup --stdin. restic writes transient pack files to$TMPDIR, so it needs a writable/tmp— the tmpfs gives it a fast, ephemeral, in-RAM one. It also keeps a persistent index cache; pointingRESTIC_CACHE_DIRat/backups/.restic-cache(the host dir) makes that cache survive runs so repeat ops don't re-fetch the index. Without a writable/tmpthe escrow dump pipe deadlocks instead of erroring — a HASteward bug to fix (provision its own tempdir + fail fast on restic error). - Timeouts:
--heal-timeout(default 600s) and--delete-timeout(default 300s) bound the wait on the re-clone and pod/PVC deletion. - No host binary needed: everything runs from the published image; HASteward itself is built in-container by StageFreight (no local Go toolchain).
Optional shortcut¶
Tired of the long line? Drop this in ~/.bashrc and then run hw <subcommand> …:
hw() {
docker run --rm --network host \
-e KUBECONFIG=/kube/config -v "$HOME/.kube:/kube:ro" \
-e RESTIC_PASSWORD="${RESTIC_PASSWORD:-}" \
-v "${HW_ESCROW:-$HOME/hasteward-escrow}:/backups" \
--tmpfs /tmp:size=4g -e RESTIC_CACHE_DIR=/backups/.restic-cache -e TMPDIR=/tmp \
docker.io/prplanit/hasteward:latest "$@"
}
# e.g. hw triage -e cnpg -c zitadel-postgres -n zeldas-lullaby
# hw repair -e cnpg -c zitadel-postgres -n zeldas-lullaby -i 3 --backups-path /backups --output jsonl
(The RESTIC_*//backups/--tmpfs bits are harmless on read-only commands; they're just unused.)