When a Formatting Command Becomes an Outage

On March 18, 2025, what started as a routine infrastructure housekeeping task turned into a full-blown outage. The trigger wasn't a bad deploy or a misconfigured network policy — it was a YAML reformatting command run against a k3s cluster's manifest directory.

The Setup: A Self-Hosted Everything Stack

The cluster in question is a small k3s installation running on a dedicated Hetzner server in Germany, with additional nodes spread across Hetzner's global regions. It hosts a custom CDN for a personal website, a Forgejo instance, and a collection of internal services. The infra repository lives on GitHub — deliberately not on Forgejo — to avoid a circular dependency where the deployment tool depends on the platform it deploys.

Deployment follows a straightforward pattern: a script uses rsync to push YAML manifests from the infra repository to a directory on the k3s server. The k3s reconciler watches that directory and applies changes automatically. There's no dry-run mode, no plan-then-apply step like you'd get with OpenTofu or Ansible — the reconciler compares the full set of .yaml files against existing Kubernetes resources on startup and performs whatever create, update, or delete operations are needed.

This workflow has known limitations. The reconciler doesn't block until an apply completes successfully, so errors surface only in the logs. To force a clean recreation of a resource, the operator has to comment it out, wait, then uncomment it. Checking logs requires SSH-ing into the server. Over time, the operator learned to predict the reconciler's behavior well enough to stop checking logs systematically, only connecting when things didn't become healthy within 30 seconds.

The Incident: A Four-Space Indentation

The editor settings had recently changed to indent all files with four spaces instead of two. Opening existing manifests revealed they were still using two-space indentation, so the operator asked the LLM assistant in the Zed editor — Claude 3.5 Sonnet in "Fast Edit" mode — for a command to reformat all of them at once.

The assistant produced a yq command. The operator installed yq — described as "jq but for YAML" — and reviewed the command superficially. It looked reasonable, and since the work was happening in a git repository, a revert seemed possible if something went wrong.

The command ran. git status showed only one file had been modified, which was disappointing. Rather than adapt the command to loop over files, the operator committed that single change, remembered to run the deploy script, and moved on to opening each manifest in the editor to format them individually.

Meanwhile, k3s logs on a second screen started scrolling furiously. The reconciler was trying — and failing — to create duplicates of resources that already existed. One file in particular, 300-cert-manager-chart.yaml, kept appearing in the logs. It was 1.75 MB. That was the first clue something was deeply wrong.

Immediate Lessons

This isn't a blameless postmortem. Several contributing factors stand out:

  • The yq man page was never consulted before running the command.
  • Only git status was checked, not git diff — a quick diff would have exposed the corruption immediately.
  • The operator was under significant stress that day, had insufficient sleep, and arguably should not have been working on infrastructure at all.

The incident illustrates how a seemingly benign tool — an LLM-suggested one-liner — can escalate into a critical infrastructure failure when validation habits slip. The k3s reconciler's lack of a dry-run mode meant the corrupted 1.75 MB manifest was applied without any intermediate checkpoint, and the only signal was frantic log output.

The broader lesson: automation that skips verification steps, combined with deployment tooling that offers no plan preview, creates a dangerous gap between intention and execution — one that formatting preferences can slip through.

A Misleadingly Simple yq Command

One command that looked perfectly reasonable turned out to be a trap. I ran:

󱆃yq -i -P '.' manifests/**/*.yaml

It didn't do what I expected at all. Instead of pretty-printing every YAML file under manifests/, this invocation merges all the matched files into the first file listed by the glob expansion. The remaining files go untouched, and the first one gets overwritten with a combined document. It's destructive and silent if you aren't paying attention.

Rather than just reading the documentation, I decided to run an experiment: ask several large language models what this command does and see if any of them flag the danger on their own.

A Field Test of LLM YAML Knowledge

GPT-4o

The default non-reasoning OpenAI model didn't see a problem. When asked to explain the command, it produced a perfectly confident breakdown: yq is a YAML processor like jq for JSON, -i edits in place, -P pretty-prints, '.' selects the entire document, and the glob targets all YAML files recursively. Its conclusion: this reformats everything neatly with consistent indentation. No warning, no caveat.

When I pointed out the merging behavior explicitly, it acknowledged the issue and explained that -i writes back to a single file: the first one in the glob. Then it offered a fix. The recommended solution was the exact same command, still carrying the same flaw. Calling that out led to an apology and, finally, two working alternatives: a find plus xargs pipeline, or a simple for loop.

Claude 3.7 Sonnet

Claude's first response was another polite, technically proper-looking breakdown of the flags and the glob pattern. It reached the same reassuring conclusion about consistent formatting across files. After the correction, Claude shifted tone and explained that the shell expands the glob, yq merges everything into the first file due to -i, and the rest remain unchanged. It called the behavior "very destructive if not intended."

Its suggested fix was cleaner than GPT-4o's — a for loop that processes each file individually. No need for xargs or managing the input field separator. Claude's acknowledgment felt more accurate: it understood the risk and didn't waste time offering the same broken command again.

DeepSeek R1

DeepSeek began with the same exhaustive, confident walkthrough of every flag and piece of syntax. It concluded that the command cleans up and standardizes formatting across files. Still oblivious to the problem.

After correction, it went on at length — far longer than necessary — restating the mechanics: the glob matches files, yq merges them by default, -i writes to the first file, and the others stay unchanged. It even created a dedicated heading to answer whether it knew about this behavior. Yes, it claimed awareness, just missed it in the initial explanation. Then it noted that merging might be useful for consolidating Kubernetes manifests.

Mistral's Le Chat

Le Chat replied with the standard explanation and even cited sources: the yq repository, the documentation front page, the troubleshooting section, and a 2021 blog post on YAML processing. The explanation was authoritative in tone and completely wrong in the same way as the others.

After the correction, Le Chat acknowledged the merging effect, restated the mechanics concisely, and mentioned the same potential use case for consolidation. Short, and to the point — no drawn-out apologies.

What This Says About the Tooling

All four models are aligned in one way: they all thank me for the correction. But none of them can apply that lesson anywhere. These models don't update from a conversation. The best hope is that the next generation's training data includes enough corrected examples like this one to learn where the edge cases live.

The underlying issue isn't new. Tools like yq have quirks that documentation bury in footnotes or troubleshooting pages. This kind of behavior is easy to discover the hard way, and apparently just as easy for even the most well-read LLM to gloss over. The practical takeaway is simple: when a glob expands to multiple files and you pass them to a tool with an in-place flag, know what that tool does with more than one input. The models may not warn you in time — but the version control diff will.

Rebuilding Day

So the cluster is wiped and I'm starting fresh. After rescuing /var/lib/rancher/k3s with rsync — which took a while thanks to 600K small files under agent/containerd and the fact that rsync is single-threaded with default compression that just slows things down on a fast link — I switched to a quicker transfer method for the 30GB payload. Being at home on 2.5Gbps made this far less painful than it could have been.

With backups confirmed in S3, I booted into Hetzner's installimage to reinstall Debian 12. One gotcha: Ghostty's TERM value of xterm-ghostty isn't recognized by the ncurses installer, which dumps you into nano instead. Setting TERM=xterm-256color fixes that. I briefly considered RHEL derivatives but decided today had enough excitement already — the applications are all containerized anyway, so the host OS matters less.

The New Cluster

I skipped exporting an etcd snapshot to S3 for restoration — this was a fresh start, not a restore. I put the edge nodes to sleep with a tellingly-named Ansible playbook and got going.

For k3s itself, the leader setup is straightforward. My Ansible roles are adapted from k3s-ansible, and the inventory is generated from OpenTofu state by a Rust script using the unstable -Zscript cargo flag — invoked via the ./ansible-playbook wrapper that sets up the environment.

A notable headache: the new k3s leader generates its own certificate authority, so nodes kept failing with CA hash mismatches. The CA hash for node authentication lives in /var/lib/rancher/k3s/server/node-token, which has the format:

K10<token-ca-hash>::server:<random-token>

I'd committed the old token value to an Ansible variable, encrypted with git-crypt, but the leader role also wrote that stale file over the fresh one. The fix was replacing the portion between K10 and :: in the node-token with the value from the failed assertion. GPT-4o explained this structure to me after I'd already figured it out the hard way by comparing hashes.

Restoring Core Services

Bringing back services started with the most essential one — Traefik. k3s ships Traefik v2 by default, but I want v3 for non-experimental HTTP/3, so I disable the bundled version and apply a manifest for v3 directly.

Next came cert-manager for Let's Encrypt certificates, Minio for object storage on the dedicated server's 2x512GB SSD, and k8up for restic-based backups — each deployed as a simple HelmChart resource.

For cross-namespace secret sharing, there's reflector. I keep services neatly separated into namespaces so I can make a mess in one and delete it wholesale — but you can't read secrets from another namespace. Reflector annotations on secrets specify which namespaces get mirrored copies.

CloudNativePG handles Postgres. Of all the controllers I've tried, it gets in my way the least, which matters a lot for stateful workloads. Its Helm chart pins the controller to the dedicated node.

Restoring Forgejo’s data layer

Forgejo is particular about its storage: it needs object storage (which lives in actual Amazon S3 for persistence) and local storage for git repositories and related data. Both had to be restored independently.

Postgres first

The database restore was routine — spin up a CloudNativePG restore job and wait for it to finish. You can watch the restore jobs with :jobs in k9s and tail their logs from there; once the job completes, the data is back.

That said, reading the CloudNativePG 1.25 documentation was the most painful part of the process. MkDocs is better than nothing, but that bar is low enough to trip over.

--- kind: Cluster apiVersion: postgresql.cnpg.io/v1 metadata: name: forgejo-db namespace: forgejo labels: cnpg.io/reload: "true" spec: instances: 1 imageName: ghcr.io/cloudnative-pg/postgresql:16 # Specify PostgreSQL 16 image primaryUpdateStrategy: unsupervised affinity: nodeSelector: kubernetes.io/hostname: brat storage: size: 10Gi pvcTemplate: metadata: annotations: # do not back up the db volume with k8up (we back it up # with barman, see below) k8up.io/backup: "false" nodeSelector: kubernetes.io/hostname: brat bootstrap: # we're doing disaster recovery aw yiss recovery: source: cluster-backup externalClusters: - name: cluster-backup barmanObjectStore: destinationPath: "s3://bearcove-cnpg-backups/forgejo/brat-1/" serverName: forgejo-db wal: compression: snappy data: compression: snappy s3Credentials: accessKeyId: name: s3-credentials key: ACCESS_KEY secretAccessKey: name: s3-credentials key: SECRET_KEY backup: barmanObjectStore: # pro-tip: this needs to be a different path, otherwise it's very unhappy at you. destinationPath: "s3://bearcove-cnpg-backups/forgejo/brat-2025-03-18/" wal: compression: snappy data: compression: snappy s3Credentials: accessKeyId: name: s3-credentials key: ACCESS_KEY secretAccessKey: name: s3-credentials key: SECRET_KEY retentionPolicy: "30d"
Amos Amos

Persistent volumes: where the trouble starts

Kubernetes treats storage like compute or memory — a fungible resource. Say how much you need, not where it lives. You can specify placement, but it’s an uphill battle.

On k3s, the local-path storage class provisions volumes as plain folders under /var/lib/rancher/k3s/storage:

--- kind: PersistentVolumeClaim apiVersion: v1 metadata: name: forgejo namespace: forgejo annotations: k8up.io/backup: "true" spec: accessModes: - ReadWriteOnce storageClassName: local-path resources: requests: storage: 20Gi
root@brat /var/lib/rancher/k3s/storage # ls -lhA total 20K drwxrwsrwx 3 root 1001 4.0K Mar 18 14:08 pvc-02c60622-d5c9-4dc5-8ea8-22a51eee0b83_minio_minio drwxrwsrwx 3 root tape 4.0K Mar 28 11:33 pvc-1d42f27a-b955-49ec-9f10-42ed222602f4_umami_umami-db-pg17-1 drwxrwsrwx 3 root tape 4.0K Mar 18 14:32 pvc-2a1953cf-8867-4980-8b59-56ff2ed6411c_forgejo_forgejo-db-1 drwxrwsrwx 4 root amos 4.0K Mar 18 15:31 pvc-a79bde12-25c0-40b9-98c3-ab16a6d12afa_forgejo_forgejo drwxrwsrwx 3 root tape 4.0K Mar 28 11:49 pvc-bfa94fbd-4105-4860-aa19-06b95d7dd573_forgejo_forgejo-db-pg17-1

Managed Kubernetes hides this complexity — volumes are provisioned by the provider’s own volume backend. On self-managed k3s running across VMs, that abstraction disappears. You can’t attach a DigitalOcean volume to a Hetzner VM. Options like Longhorn or Ceph (usually via Rook) sound attractive, but they really want three to five dedicated servers with comparable specs in the same datacenter. Local storage is often the pragmatic choice.

The catch: a persistent volume claim isn’t provisioned until a pod actually mounts it. If the first pod to touch it is a k8up restore pod running restic, and that pod lands on a small edge node instead of your dedicated server, the volume ends up in the wrong place:

Cool bear

That’s why the redeployment started with manifests split into files prefixed with three digits — imposing order on an orchestrator that doesn’t understand it. k3s ignores the numbering; the files were staged in old-manifests/ and copied into manifests/ manually, one at a time:

infra/manifests/forgejo on  main [$] l Permissions Size User Date Modified Name .rw-r--r--@ 115 amos 18 Mar 18:27 000-forgejo-namespace.yaml .rw-r--r--@ 4.8k amos 28 Mar 13:27 001-forgejo-config-secret.yaml .rw-r--r--@ 4.2k amos 28 Mar 16:40 100-forgejo-db-cluster.yaml .rw-r--r--@ 643 amos 28 Mar 13:27 101-forgejo-db-backups.yaml .rw-r--r--@ 927 amos 18 Mar 18:27 200-forgejo-persistent-volumes.yaml .rw-r--r--@ 161 amos 18 Mar 18:27 201-forgejo-backup-secrets.yaml .rw-r--r--@ 1.4k amos 18 Mar 18:27 202-forgejo-backups.yaml .rw-r--r--@ 1.2k amos 18 Mar 18:27 203-forgejo-backup-schedule.yaml .rw-r--r--@ 1.5k amos 23 Mar 16:57 300-forgejo-deployment.yaml .rw-r--r--@ 1.2k amos 23 Mar 16:57 400-forgejo-ingress.yaml .rw-r--r-- 33 amos 18 Mar 17:14 README.md
Cool bear

To correct a volume on the wrong node, force provisioning where you want it by creating a dummy pod:

--- ## This forces the volume to be created on node 'brat' apiVersion: v1 kind: Pod metadata: name: forgejo-ls2 namespace: forgejo spec: restartPolicy: Never securityContext: runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 containers: - name: ls-container image: busybox command: ["ls", "-lhA", "/workdir"] volumeMounts: - name: forgejo-workdir mountPath: /workdir nodeSelector: kubernetes.io/hostname: brat volumes: - name: forgejo-workdir persistentVolumeClaim: claimName: forgejo

Absurd? Probably. Effective? Absolutely.

Restoring with k8up when you’re not root

The actual k8up restore had its own quirks. The Forgejo image is rootless — inside the container, processes run as user git with UID 1000:

~ kubectl exec forgejo-f9dd988c4-9svkx -n forgejo -it -- /bin/bash forgejo-f9dd988c4-9svkx:/var/lib/gitea$ whoami git forgejo-f9dd988c4-9svkx:/var/lib/gitea$ id uid=1000(git) gid=1000(git) groups=1000(git) forgejo-f9dd988c4-9svkx:/var/lib/gitea$ exit

A normal k8up restore produces files owned by root, which the git user can’t read. The fix is to specify a podSecurityContext so the restore pod also runs as UID 1000:

apiVersion: k8up.io/v1 kind: Restore # ✂️ spec: podSecurityContext: runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 fsGroupChangePolicy: "OnRootMismatch" # ✂️

That introduces a different failure: restic can’t write to its own cache directory. Restic first issues a burst of S3 GET requests to inventory backups, storing the results locally because it consults that cache constantly during the restore. If the cache is unwritable, restic still works but becomes so slow it may as well not be trying:

Cool bear

Setting RESTIC_CACHE_DIR to a writable path solves it — and so does giving the job generous CPU and memory limits:

--- ############################################################################## # k8up backup/restore setup ############################################################################## apiVersion: v1 kind: ConfigMap metadata: name: restic-vars namespace: forgejo data: RESTIC_CACHE_DIR: /tmp/restic-cache --- apiVersion: k8up.io/v1 kind: Restore metadata: name: restore-workdir-2024-03-18-b namespace: forgejo spec: snapshot: e9e9a75d # last `/data/forgejo` backup podSecurityContext: runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 fsGroupChangePolicy: "OnRootMismatch" restoreMethod: folder: # restore to PVC forgejo claimName: forgejo backend: repoPasswordSecretRef: name: backup-repo key: password resources: requests: cpu: 10 memory: 1Gi limits: cpu: 10 memory: 4Gi envFrom: - configMapRef: name: restic-vars s3: bucket: bearcove-k8up-backups endpoint: https://s3.eu-central-1.amazonaws.com accessKeyIDSecretRef: name: s3-credentials key: ACCESS_KEY secretAccessKeySecretRef: name: s3-credentials key: SECRET_KEY

Deployment, service, ingress

One combined manifest for Forgejo covers the essentials. The service exposes forgejo.forgejo.svc.cluster.local:80 to the namespace:

--- kind: Service apiVersion: v1 metadata: name: forgejo namespace: forgejo spec: selector: app: forgejo ipFamilyPolicy: RequireDualStack ports: - protocol: TCP port: 80 targetPort: http

A certificate request handles TLS for the instance’s domain:

--- apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: tls namespace: forgejo spec: secretName: tls-secret issuerRef: name: letsencrypt-prod kind: ClusterIssuer dnsNames: [redacted.example.org]

The ingress raises the max POST limit — needed when uploading “generic” packages to a Forgejo registry:

--- apiVersion: traefik.io/v1alpha1 kind: Middleware metadata: name: request-body-limit namespace: forgejo spec: buffering: maxRequestBodyBytes: 1073741824 # 1 GiB memRequestBodyBytes: 67108864 # 64 MiB

And the ingress route ties it together:

--- apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: forgejo namespace: forgejo spec: entryPoints: - websecure routes: - match: Host(`redacted.example.org`) kind: Rule services: - name: forgejo port: 80 middlewares: - name: request-body-limit namespace: forgejo tls: secretName: tls-secret
Cool bear

The ingress configuration is verbose and specific, even by Kubernetes standards. Labels could shorten it, but that never quite worked out. Most people use nginx for ingress; between C and Go, the lesser evil wins.

Bringing home

The website software, currently named home, also lives on k3s. It has no persistent database: assets sit in object storage, and a central “mom” service keeps one SQLite database per tenant. That database tracks which assets were uploaded, the latest revision, and sponsors fetched from GitHub and Patreon:

################################################################################ # 🐻 MOM DEPLOYMENT ################################################################################ --- kind: Deployment apiVersion: apps/v1 metadata: name: mom namespace: home labels: group: home app: mom spec: replicas: 1 selector: matchLabels: app: mom template: metadata: labels: group: home app: mom spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: node-type operator: In values: - dedicated imagePullSecrets: - name: forgejo-docker-pull-secrets containers: - name: mom image: redacted.example.org/bearcove/home:32.2.4 command: ["home", "mom"] workingDir: /var/lib/home envFrom: - secretRef: name: home-vars - secretRef: name: home-conf env: - name: RUST_LOG value: "info" - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name - name: NODE_NAME valueFrom: fieldRef: fieldPath: spec.nodeName ports: - containerPort: 1118 name: http readinessProbe: httpGet: path: /health port: http initialDelaySeconds: 1 periodSeconds: 1 resources: requests: memory: "400Mi" cpu: "0.5" limits: memory: "16000Mi" cpu: "20" volumeMounts: - name: mom mountPath: /var/lib/home - name: metadata mountPath: /metadata readOnly: true volumes: - name: mom persistentVolumeClaim: claimName: mom - name: metadata hostPath: path: /metadata type: DirectoryOrCreate

Edge nodes are called “cubs”, and the deployment manifest is interesting — it runs one pod per edge node, tolerates downtime on some pods, and routes to the closest healthy node:

################################################################################ # 🧸 CUB DEPLOYMENT ################################################################################ --- kind: Deployment apiVersion: apps/v1 metadata: name: cub namespace: home labels: group: home app: cub spec: replicas: 6 # 1 dedicated + 5 edge nodes topologySpreadConstraints: # Define topology spread constraints for the deployment - maxSkew: 1 # defines the maximum skew between the number of pods in different topology domains # Use zone as the topology key topologyKey: topology.kubernetes.io/zone # Allow scheduling even if constraints are not met whenUnsatisfiable: ScheduleAnyway # Specify the label selector for the pods labelSelector: matchLabels: # Match pods with the label app: cub app: cub affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchExpressions: - key: node-type operator: In values: - cloud topologyKey: "kubernetes.io/hostname" selector: matchLabels: app: cub template: metadata: labels: app: cub group: home spec: imagePullSecrets: - name: forgejo-docker-pull-secrets containers: - name: cub image: redacted.example.org/bearcove/home:32.2.4 command: ["home", "serve"] envFrom: - secretRef: name: home-vars - secretRef: name: home-conf env: - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name - name: NODE_NAME valueFrom: fieldRef: fieldPath: spec.nodeName ports: - containerPort: 1111 name: http # define readiness probe: must serve HTTP on port 1111 readinessProbe: httpGet: path: / port: http httpHeaders: - name: x-forwarded-host value: fasterthanli.me initialDelaySeconds: 1 periodSeconds: 1 resources: requests: memory: "400Mi" cpu: "0.5" limits: memory: "1200Mi" cpu: "12" volumeMounts: - name: cub mountPath: /var/lib/home - name: metadata mountPath: /metadata readOnly: true volumes: - name: cub persistentVolumeClaim: claimName: cub - name: metadata hostPath: path: /metadata type: DirectoryOrCreate

Zero-downtime deploys

This setup permits zero-downtime deploys. When everything is healthy, the home namespace looks like this, with all cub pods up:

k9s screenshot, showing cub with 6/6 pods ready, and mom with 1/1 pods ready.
6 pods are listed now, they each have 1/1 containers ready, they have status running, 0 restarts, use between 30 and 49 CPU and between 360 and 522 Mem. They have internal IPs and are running on nodes named marl, flam, brat, kaya, heim, hawk.

Introducing a deliberate crash exposes the safety net. First attempt — a change that breaks the build — fails at CI, never reaching the cluster:

home on  main via 🦀 v1.85.1 g show commit 984d0f5b7fa5cff9172ea1bc16091b1fbc6bed6a (HEAD -> main, origin/main, origin/HEAD) Author: Amos Wenger <[email protected]> Date: Fri Mar 28 17:35:46 2025 +0100 crash on purpose diff --git a/crates/home/src/main.rs b/crates/home/src/main.rs index 119e3e91..959fa414 100644 --- a/crates/home/src/main.rs +++ b/crates/home/src/main.rs @@ -30,6 +30,8 @@ async fn real_main() -> eyre::Result<()> { errhandling::load().install(); tracingsub::load().install(); + panic!("woopsie doopsie I'm doing a demo"); + let args = clap::load().parse(); let res = match args.sub {
forgejo actions screenshot, showing that my CI pipeline is running --version on the binary, so it failed the job.

The second attempt — a change that only crashes on serve in production — passes pipelines, offering a chance to demonstrate the automation:

home on  main [!] via 🦀 v1.85.1 gwd crates/mod-cub/src/lib.rs --- Rust 8 #[dylo::export] 9 impl Mod for ModImpl { 10 fn serve(&self, config: Config) -> BoxFuture<'static, Result<()>> { 11 Box::pin(async { 12 if std::env::var("KUBERNETES_SERVICE_HOST").is_ok() { 13 panic!("is this a container? LET ME OUT! LEMMEOUT"); 14 } 15 16 impls::serve(config) 17 .await 18 .map_err(|e| noteyre::eyre!("{}", e))
home on  main via 🦀 v1.85.1 bd bump Fetched all tags from remote. Latest tag: v32.2.4 Choose version bump type: 1. Patch (32.2.5) 2. Minor (32.3.0) 3. Major (33.0.0) 1 Creating new tag: v32.2.5 Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 To https://redacted.example.org/bearcove/home * [new tag] v32.2.5 -> v32.2.5 Tag v32.2.5 created and pushed successfully
Cool bear

(bd is short for beardist.) The -build jobs publish a generic package:

The generic package page for home, which contains curl -OJ instructions generated by forgejo. You can see it's built for x86_64-unknown-linux-gnu and for aarch64-apple-darwin

They also trigger a homebrew tap update (that’s the trigger-formula-update job) and push a container image:

The home container package, containing `docker pull` instructions, a sha256 digest. it's for the linux/amd64 platform and is 427 Mib total.

That image isn’t rolled out automatically — the green light comes from a human, also via beardist. The k8s subcommand first finds where the given image is referenced:

infra on  main [$] via 🦀 v1.85.0 bd k8s bearcove/home Searching for manifests in: manifests YAML files containing 'bearcove/home' are: File: manifests/home/301-home-cub-deployment.yaml Version 32.2.4 at positions 1761 to 1808 Context: >>> - name: cub >>> image: redacted.example.org/bearcove/home:32.2.4 >>> command: ["home", "serve"] >>> envFrom: >>> - secretRef: File: manifests/home/300-home-mom-deployment.yaml Version 32.2.4 at positions 1074 to 1121 Context: >>> - name: mom >>> image: redacted.example.org/bearcove/home:32.2.4 >>> command: ["home", "mom"] >>> workingDir: /var/lib/home >>> envFrom:

Then it polls the Forgejo instance, waiting for a different version to appear:

Initializing Forgejo client... Checking for new versions... Fetching latest version for package 'home' from 'https://redacted.example.org/api/v1/packages/bearcove' Request completed in 385ms with status 200 OK Received 13 packages in response Filtered to 12 matching packages Found 11 valid versions Latest version found: 32.2.5 New version detected: 32.2.5

When it shows up, the code updates the manifests:

Updating manifests... Updated manifests/home/301-home-cub-deployment.yaml Updated manifests/home/300-home-mom-deployment.yaml Staging changes... Showing staged changes: manifests/home/300-home-mom-deployment.yaml --- YAML 33 imagePullSecrets: 34 - name: forgejo-docker-pull-secrets 35 containers: 36 - name: mom 37 image: redacted.example.org/bearcove/home:32.2.4 37 image: redacted.example.org/bearcove/home:32.2.5 38 command: ["home", "mom"] 39 workingDir: /var/lib/home 40 envFrom: 41 - secretRef: manifests/home/301-home-cub-deployment.yaml --- YAML 48 imagePullSecrets: 49 - name: forgejo-docker-pull-secrets 50 containers: 51 - name: cub 52 image: redacted.example.org/bearcove/home:32.2.4 52 image: redacted.example.org/bearcove/home:32.2.5 53 command: ["home", "serve"] 54 envFrom: 55 - secretRef: 56 name: home-vars

Commits and pushes:

Committing changes... > [email protected] lint-staged > lint-staged No staged files match any configured task. [main d37d8eb] bump bearcove/home to 32.2.5 2 files changed, 2 insertions(+), 2 deletions(-) Pushing changes... Enumerating objects: 10, done. Counting objects: 100% (10/10), done. Delta compression using up to 8 threads Compressing objects: 100% (6/6), done. Writing objects: 100% (6/6), 1.17 KiB | 1.17 MiB/s, done. Total 6 (delta 5), reused 0 (delta 0), pack-reused 0 remote: Resolving deltas: 100% (5/5), completed with 4 local objects. To https://github.com/bearcove/infra.git 89bddc6..d37d8eb main -> main

And finally calls ./deploy-manifests, which picked up some new tricks — mostly the right rsync flags to preview changes before applying them:

Deploying manifests... 🔍 Performing dry run... Source: ./manifests/ Destination: [email protected]:/var/lib/rancher/k3s/server/manifests/custom/ ================================================== 🚨 REVIEW THIS CAREFULLY 🚨 ================================================== The following changes will be made: ================================================== <fc.T.... home/300-home-mom-deployment.yaml <fc.T.... home/301-home-cub-deployment.yaml ================================================== Please review the above changes carefully before proceeding. ================================================== 🚨 Warning: This will perform the changes above. Are you sure you want to continue? (y/n) y 🔧 Performing operations... 📁 Creating skip file: /var/lib/rancher/k3s/server/manifests/traefik.yaml.skip 🔄 Syncing files... 📜 Viewing logs... ✂️ building file list ... 60 files to consider home/300-home-mom-deployment.yaml 2.92K 100% 2.12MB/s 0:00:00 (xfer#1, to-check=29/60) home/301-home-cub-deployment.yaml 3.71K 100% 3.53MB/s 0:00:00 (xfer#2, to-check=28/60) sent 4.41K bytes received 130 bytes 3.02K bytes/sec total size is 1.07M speedup is 236.91
󱆃# in `deploy-manifests` #!/bin/bash -euo pipefail SRC="./manifests/" DST="[email protected]:/var/lib/rancher/k3s/server/manifests/custom/" # Define rsync flags RSYNC_FLAGS=(--recursive --delete --checksum --human-readable --progress --include='*/' --include='*.yaml' --exclude='*') # Perform a dry run of rsync to show what would happen echo "🔍 Performing dry run..." printf "Source: \033[33m%s\033[0m\n" "$SRC" printf "Destination: \033[33m%s\033[0m\n" "$DST" printf "\033[2m==================================================\033[0m\n" printf "\033[2m🚨 REVIEW THIS CAREFULLY 🚨\033[0m\n" printf "\033[2m==================================================\033[0m\n" printf "\033[2mThe following changes will be made:\033[0m\n" printf "\033[2m==================================================\033[0m\n" rsync "${RSYNC_FLAGS[@]}" --dry-run --itemize-changes "$SRC" "$DST" printf "\033[2m==================================================\033[0m\n" printf "\033[2mPlease review the above changes carefully before proceeding.\033[0m\n" printf "\033[2m==================================================\033[0m\n" # Ask for consent before continuing printf "\n🚨 \033[1;31mWarning:\033[0m This will perform the changes above.\n" read -p "Are you sure you want to continue? (y/n) " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]] then printf "❌ \033[1;31mOperation cancelled.\033[0m\n" exit 1 fi # The actual operations echo "🔧 Performing operations..." printf "📁 Creating skip file: \033[33m/var/lib/rancher/k3s/server/manifests/traefik.yaml.skip\033[0m\n" ssh root@brat "touch /var/lib/rancher/k3s/server/manifests/traefik.yaml.skip" & echo "🔄 Syncing files..." rsync "${RSYNC_FLAGS[@]}" "$SRC" "$DST" & echo "📜 Viewing logs..." ssh -t root@brat "journalctl -fxu k3s | ccze -A"

After the k3s reconciler wakes up, the rollout is visible:

The cub deployment now has 5/8 pods ready.
We have 5 healthy pods, the other 3 have 0/1 ready containers, and they're in status CrashLoopBackOff. They have 6 restarts already.
The logs show: reading config from HOME_JSON_BASE64 environmental variable, then decoded 5358 bytes, 138 lines. Reading config from env (HOMECONF_ prefix) and /tmp/home.json (in green), then: The application panicked (crashed).
Message: is this a container? LET ME OUT! LEMMEOUT
Location: crates/mod-cub/src/lib.rs:13
Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it. Run with RUST_BACKTRACE=full to include source snippets.
stream closed EOF for home/cub-764bfbdfd-lhwg6 (cub)

But the site stays up the whole time:

A screenshot of keycdn's Performance Test. It shows TTFB varying from 47ms (in Singapore) to 75ms (most locations), to 162ms in Bangalore and 402ms in Sydney.

At that point, the choice is roll back:

infra on  main [$] via 🦀 v1.85.0 g revert d37d8eb5fdf101dfb70427a845fee8924c058dad [main d9602ff] Revert "bump bearcove/home to 32.2.5" 2 files changed, 2 insertions(+), 2 deletions(-) infra on  main [$⇡] via 🦀 v1.85.0 gp Enumerating objects: 10, done. Counting objects: 100% (10/10), done. Delta compression using up to 8 threads Compressing objects: 100% (6/6), done. Writing objects: 100% (6/6), 1.23 KiB | 1.23 MiB/s, done. Total 6 (delta 5), reused 0 (delta 0), pack-reused 0 remote: Resolving deltas: 100% (5/5), completed with 4 local objects. To https://github.com/bearcove/infra.git 35d0771..d9602ff main -> main infra on  main [$] via 🦀 v1.85.0 ./deploy-manifests 🔍 Performing dry run... Source: ./manifests/ Destination: [email protected]:/var/lib/rancher/k3s/server/manifests/custom/ ================================================== 🚨 REVIEW THIS CAREFULLY 🚨 ================================================== The following changes will be made: ================================================== <fc.T.... home/300-home-mom-deployment.yaml <fc.T.... home/301-home-cub-deployment.yaml ================================================== Please review the above changes carefully before proceeding. ================================================== 🚨 Warning: This will perform the changes above. Are you sure you want to continue? (y/n) y 🔧 Performing operations... 📁 Creating skip file: /var/lib/rancher/k3s/server/manifests/traefik.yaml.skip 🔄 Syncing files... 📜 Viewing logs... Mar 28 18:21:43bratk3s[3054412]: I0328 18:21:43.1789393054412scope.go:117] "RemoveContainer" containerID="125d2b6f93b70a5d2ff61c941c1e44128d6d847e82b7f526a8a6609caf970e11" Mar 28 18:21:43bratk3s[3054412]: E0328 18:21:43.1791993054412pod_workers.go:1301] "Error syncing pod, skipping" err="failed to \"StartContainer\" for \"cub\" with CrashLoopBackOff: \"back-off 5m0srestartingfailed container=cub pod=cub-764bfbdfd-pmst4_home(d8884c82-0983-476a-ae97-ef6a89de2e08)\"" pod="home/cub-764bfbdfd-pmst4" podUID="d8884c82-0983-476a-ae97-ef6a89de2e08" Mar 28 18:21:46bratk3s[3054412]: I0328 18:21:46.1886323054412range_allocator.go:247] "Successfully synced" key="brat" Mar 28 18:21:57bratk3s[3054412]: I0328 18:21:57.1791423054412scope.go:117] "RemoveContainer" containerID="125d2b6f93b70a5d2ff61c941c1e44128d6d847e82b7f526a8a6609caf970e11" Mar 28 18:21:57bratk3s[3054412]: E0328 18:21:57.1795083054412pod_workers.go:1301] "Error syncing pod, skipping" err="failed to \"StartContainer\" for \"cub\" with CrashLoopBackOff: \"back-off 5m0srestartingfailed container=cub pod=cub-764bfbdfd-pmst4_home(d8884c82-0983-476a-ae97-ef6a89de2e08)\"" pod="home/cub-764bfbdfd-pmst4" podUID="d8884c82-0983-476a-ae97-ef6a89de2e08" Mar 28 18:22:09bratk3s[3054412]: I0328 18:22:09.1798913054412scope.go:117] "RemoveContainer" containerID="125d2b6f93b70a5d2ff61c941c1e44128d6d847e82b7f526a8a6609caf970e11" Mar 28 18:22:09bratk3s[3054412]: E0328 18:22:09.1801863054412pod_workers.go:1301] "Error syncing pod, skipping" err="failed to \"StartContainer\" for \"cub\" with CrashLoopBackOff: \"back-off 5m0srestartingfailed container=cub pod=cub-764bfbdfd-pmst4_home(d8884c82-0983-476a-ae97-ef6a89de2e08)\"" pod="home/cub-764bfbdfd-pmst4" podUID="d8884c82-0983-476a-ae97-ef6a89de2e08" Mar 28 18:22:21bratk3s[3054412]: I0328 18:22:21.1784353054412scope.go:117] "RemoveContainer" containerID="125d2b6f93b70a5d2ff61c941c1e44128d6d847e82b7f526a8a6609caf970e11" k3siles to consider 0 files... home/300-home-mom-deployment.yaml 2.92K 100% 2.12MB/s 0:00:00 (xfer#1, to-check=29/60) home/301-home-cub-deployment.yaml 3.71K 100% 3.53MB/s 0:00:00 (xfer#2, to-check=28/60) sent 4.41K bytes received 130 bytes 3.02K bytes/sec total size is 1.07M speedup is 236.91

Or push a fix and roll forward:

Amos

Closing words

The disaster recovery went well. The control node didn’t need a full teardown and rebuild, nor did the manifests require careful reorganization — but since the system was already down and the month had been rough, the cleanup felt worth it.

The deploy-manifests script still blows, even in its latest incarnation. A better version would merge all resources into a single YAML file and diff against the control node’s state, requiring a --fuck-me-up flag if more than two resources get deleted. But that’s incrementalism; what’s really needed is a proper continuous deployment solution with progressive rollouts and automated rollbacks. Until then, caution will have to suffice — and the knowledge that no data was lost helps.

Amos

Storing repositories only in Forgejo without GitHub mirrors felt risky at first, but this wasn’t the first disaster, and recovery went smoothly each time.

Cool bear

As for Taiga for Kanban: no. Teamhood’s free tier covers everything needed for now.

Amos

The fine folks at the self-directed research podcast will hear more about beardist next season. In the meantime, here’s a popular episode:

Video thumbnail showing the episode title: the embedded buddy system