Using the lab
Scenario guide
Every scenario that ships, and what each one teaches.
docs/scenarios.mdOn this page
- How they work
- Available scenarios
- Observability & SRE (observability-sre)
- GitOps & CI/CD (gitops-cicd)
- Security & Compliance (security-compliance)
- Chaos Engineering (chaos-engineering)
- Autoscaling Under Load (autoscaling-under-load)
- Multi-Env Promotion (env-promotion)
- Mesh Traffic Management (mesh-traffic-management)
- Event-Driven Architecture (event-driven-arch)
- Secrets Management & Rotation (secrets-management)
- Day-2 Drill: Node Drain Under Load (node-drain-drill)
- Day-2 Drill: Rolling Cluster Upgrade Under Load (cluster-upgrade-drill)
- Day-2 Drill: Namespace Backup & Restore (backup-restore-drill)
- Cost & Capacity: Right-Sizing (cost-right-sizing)
Scenarios are declarative labs. Each installs a set of related tools, configurations and dashboards so you can explore one platform-engineering concept hands-on, then grades your work with machine-verifiable checks.
- Writing one? The full YAML reference is the scenario schema, and the guided walkthrough is your first scenario.
- Running one? The commands are in the CLI reference.
This page is the catalog of what ships in the repository.
How they work
A scenario is a directory under scenarios/ containing a scenario.yaml that
declares its prerequisites (platform components and apps that must be running),
its components (Helm charts, manifests, dashboards, scripts), its checks, and
explore hints. The engine handles installation order, template resolution and
state tracking.
labctl scenario list # what is available, and what is active
labctl scenario info observability-sre # description, prerequisites, components, hints
labctl scenario up observability-sre # activate
labctl scenario verify observability-sre # grade
labctl scenario down observability-sre # deactivatescenario up validates prerequisites, installs each component in order, marks
the scenario active and prints the explore tips. scenario down removes what it
installed. The web UI (labctl ui) does the same in one click.
Available scenarios
Observability & SRE (observability-sre)
Category: observability
What it stages:
- Loki (log aggregation,
grafana-community/loki): adopted when the platform already installed it - Promtail (log shipping agent,
grafana/promtail; deprecated upstream, see ADR-0012) - Tempo (trace backend,
grafana-community/tempo) - Grafana Alloy (OTLP trace collector: the app exports here, Alloy forwards to Tempo)
- Alerting rules (PrometheusRule CRDs: error rate, latency, memory saturation, CPU throttling, readiness)
- Three dashboards: Golden Signals (with a Tempo traces panel), an SLO dashboard, and a Log Explorer
What it deliberately does not do: wire the application to the collector.
That is objective 1, and a stage that performs it leaves a check asserting what
the stage just set. The reset-tracing-wiring component runs
disable-tracing.sh on activation and on teardown, so every run starts
unwired and ends clean.
The drill:
- Wire the app to Alloy -
kubectl set env deployment/<app> OTEL_EXPORTER_OTLP_ENDPOINT=...(or runscripts/enable-tracing.sh). The app never learns which trace backend is behind the collector. - Drive real load with k6:
labctl traffic start --profile steady --rps 25. - Follow one request across all three signals - Loki gives a
trace_id, Grafana turns it into a jump to that trace in Tempo. - Break readiness on purpose and take
PodNotReadyfrom inactive to firing.
Prerequisites:
- Platform: ingress, monitoring/metrics, monitoring/grafana
- Apps: the bound workload, which must declare
prometheus-metrics,otlp-tracingandreadiness-toggle
Checks (11): six guard the stack labctl stands up (Loki, Promtail, Tempo,
Alloy, the PrometheusRule's release label, Grafana). Five grade the drill and
are pending until it is done: the app is under real load, it exports to the
collector, spans arrived in Tempo, its logs are queryable in Loki and carry
trace ids, and an alert from this scenario actually fired.
The pairing matters. app-exports-traces-to-collector reads the environment
variable and gives the exact command to fix it; traces-arrived-in-tempo reads
Tempo. An endpoint can be set on a Deployment that never sends a span (wrong
port, collector down, no traffic) and all of those pass the setting check.
Traps this scenario encodes:
- History satisfies an unwindowed query. Tempo and Loki retain what a
previous run produced, so a check that asks "are there spans?" is green the
moment the lab has ever done this exercise. Both script checks bound their
queries to a recent window (
LOOKBACK_SECONDS, default 900). Tempo filters at block granularity, so this rules out a run from yesterday rather than one from ten minutes ago. - Two of the six alert rules could never fire. They referenced
container_spec_memory_limit_bytesandcontainer_cpu_cfs_throttled_seconds_total, neither of which exists on k3s or kind. The limit now comes fromkube_pod_container_resource_limits(kube-state-metrics) and throttling from the ratio ofcontainer_cpu_cfs_throttled_periods_totaltocontainer_cpu_cfs_periods_total. A rule with a dead expression reportshealth: okand staysinactiveforever. - Tempo's HTTP API listens on 3200, not 3100. A Grafana datasource on the wrong port fails every query and looks exactly like "no traces were recorded". The check scripts query through Grafana's datasource proxy for this reason: probing Tempo directly would pass with a broken datasource, which is the path the learner actually clicks.
Load is generated with k6 (labctl traffic start), not a curl loop, so latency
changes show as queueing at a constant arrival rate. See runbook
R13 for the end-to-end validation.
Explore after activation:
- Golden Signals dashboard:
http://grafana.snowops.localhost/d/observability-sre - Explore > Loki >
{namespace="<workload-ns>"} | json: expand a line, click itstrace_id - Explore > Tempo > search by
service.name - Check alerts: Prometheus > Alerts, or
kubectl -n monitoring get prometheusrules
GitOps & CI/CD (gitops-cicd)
Category: delivery
What it deploys:
- A Git server in the cluster (namespace
gitops), serving a bare repo overgit://on a PVC, seeded with two paths:demo/(a working release) andbroken/(a release that cannot become healthy) - ArgoCD: adopted from the
gitops/argocdplatform component, never a second copy of it - Two ArgoCD Applications, each watching one of those paths, both with automated sync, prune and self-heal
- ServiceMonitors for ArgoCD's three metrics endpoints, and a Grafana dashboard
(
gitops-delivery) plotting sync and health state
Prerequisites:
- Platform: ingress, gitops/argocd, monitoring/metrics, monitoring/grafana
- Apps: none
Checks (10). Six are always enforced; four are the drill, and each one grades an objective that used to be described and never tested:
| Pending check | What it proves |
|---|---|
learner-pushed-a-revision |
demo/deployment.yaml at HEAD declares something other than the seed commit did |
self-heal-observed |
ArgoCD scaled the Deployment back from a replica count Git has never declared |
prune-removed-a-resource |
demo/service.yaml is absent from Git and the Service is gone from the cluster |
broken-app-repaired |
the gitops-broken Application is Synced and Healthy |
The drill: clone the in-cluster repo over a port-forward, change the declared
image tag and replica count, push, and watch ArgoCD reconcile it. Then prove the
three things that separate GitOps from kubectl: scale the Deployment by hand
and be reverted, delete a manifest from Git and watch the resource leave, and
repair the deliberately-broken release - in Git, because kubectl set image is
reverted within seconds.
Explore after activation:
- Open the ArgoCD dashboard at
http://argocd.snowops.localhost(admin / the password fromkubectl -n argocd get secret argocd-initial-admin-secret) kubectl -n gitops port-forward svc/git-server 9418:9418, thengit clone git://127.0.0.1:9418/platform.git- Edit
demo/deployment.yaml, push, and force a sync rather than waiting for the ~3 minute poll
Synceddoes not meanworking.gitops-brokensyncs perfectly, its manifests are valid YAML, and is stillDegraded, because the image tag in them does not exist. Sync status is a statement about your pipeline; health status is a statement about your release. Telling them apart is objective 7.
Self-heal is graded from the event trail, not from catching it live. The revert lands in a couple of seconds, ArgoCD appends no
.status.historyentry for it, andargocd_app_sync_totalresets when the controller restarts. What survives is aScalingReplicaSetevent scaling from a count Git never declared to the count it does, which a rolling update cannot produce, since its own steps only move between declared counts.
Do not compare
.status.sync.revisionwith repo HEAD. It is the revision ArgoCD last compared at, so it lags behind HEAD after a commit to a path the Application does not watch, and jumps past the last commit that touched its own path after the next sync. Both comparisons produce a check that is red for reasons the learner did not cause.
The Git daemon is unauthenticated, which is what makes the loop performable in a lab. It is the one thing here that must not be copied into a real cluster.
Security & Compliance (security-compliance)
Category: security
What it deploys:
- Kyverno 3.9.0 (admission controller, via Helm)
- cert-manager v1.21.1 (via Helm) plus the lab CA chain:
selfsigned-issuer→lab-ca→lab-ca-issuer - 4 Kyverno ClusterPolicies, all shipped in Audit so activation disrupts
nothing: promoting them is the drill:
deny-privilege-escalationrequire-non-root-userrequire-resource-limitsdisallow-latest-tag
- Default-deny NetworkPolicies for the the bound workload namespace, plus the allowances a real workload needs: DNS, intra-namespace, mesh control plane, ingress and scraping
legacy-reporter: a stand-in vendor workload violating three of the four policies, which the learner must exempt rather than fix- Security Grafana dashboard (
/d/security-compliance): policy violations by mode and by policy, certificate expiry, admission latency
Prerequisites:
- Platform: ingress, monitoring/metrics, monitoring/grafana
- Apps: go-api
What you do (four graded tasks, shown as PENDING until you complete them):
- Read the policy report, then remediate the workload so it satisfies every
policy -
kubectl -n <workload-ns> get policyreports -o wide - Promote the two Pod Security policies Audit → Enforce, and prove the webhook
now rejects a root Pod with
kubectl apply --dry-run=server - Triage the one you cannot fix: exempt
legacy-reporterwith a label-scopedPolicyException. The grading checks the scope - an exception matching the namespace instead of the label fails it - Issue the
go-api-tlsCertificate fromlab-ca-issuer, wire it onto the Ingress, and prove the lab-signed leaf is what answers the TLS handshake
Order matters: promoting to Enforce before the workload is compliant rejects go-api's own next rollout, which is exactly why Audit mode exists.
Explore after activation:
labctl scenario info security-compliance: twelve numbered commands walk the whole drill- Security dashboard at
http://grafana.snowops.localhost/d/security-compliance - Prove isolation: a pod in another namespace gets HTTP 000, while the ingress path still returns 200
Traps this scenario documents:
- A Kyverno
patternasserts on the path you wrote.runAsNonRootset at pod level does not satisfy a pattern written against the container securityContext. - Default-deny egress starves an injected mesh sidecar of istiod, so every
new pod sits at 1/2 Running with the app container healthy - the
allow-mesh-control-planerule is what prevents it. - Kyverno's CRD storage-version migration hook fails on upgrade
(
Error: Unauthorized), so it is disabled in the platform values; see R05. features.policyExceptionshas two switches.enabled: truealone leavesnamespace: '', which restricts exceptions to a single unnamed namespace - the feature reads as on while every exception is silently ignored. The platform values set it to'*'.- A
PolicyException'sruleNamesmust list theautogen-variants too, or the Pod is exempt while the Deployment that creates it is still rejected.
What NetworkPolicy actually enforces on k3d: measured, 2026-09-09. Default-deny is real for ordinary pod-to-pod traffic: with
default-deny-allin place a pod indefaultgetscurlexit code000, and with no policies at all the same probe gets200. But traffic from the ingress controller and from Prometheus reaches the workload whether or not a rule admits them - both kept working with onlydefault-deny-allapplied. In testing, adding a well-formednamespaceSelectoringress allow did not re-open a blocked pod-to-pod flow either. Soallow-ingress-trafficandallow-monitoring-scrapeare correct Kubernetes and belong in the manifest, but their effect is not demonstrable on this runtime; the deny is. The root cause has not been isolated , likely source-NAT or a k3s NetworkPolicy controller limitation, and is tracked as open.
namespace-isolation-enforcednow proves enforcement, not just correctness. Reading the policy set shows the rules are written right and says nothing about whether the CNI applies them, and a cluster that ignores NetworkPolicy entirely passes every YAML assertion while the scenario teaches nothing. The check ends by opening a connection from a pod outside the namespace and requiring it to be refused. Verified both ways:200with the policies removed,000with them in place.
Chaos Engineering (chaos-engineering)
Category: reliability
What it deploys:
- A
ServiceMonitorfor the Chaos Mesh controller-manager: the chart ships none, and itsprometheus.serviceMonitorvalues key is silently ignored - A
PodDisruptionBudgetfor the workload (minAvailable: 1), deliberately against a single-replica Deployment - A staging script that puts the shared workload back to one replica and clears any chaos objects an earlier run left behind, so the drill's premise actually holds; teardown restores the replica count it found
- A chaos Grafana dashboard: active/total experiments, PDB disruptions allowed, restarts, request rate, latency, CPU/memory, OOMKills
- Six Chaos Mesh experiments as snippets, applied one at a time by the learner: pod-kill, pod-failure, network-partition, network-delay, cpu-stress, memory-stress
Chaos Mesh itself is a platform prerequisite (chaos/chaos-mesh), not a
scenario component: its installer picks the containerd socket per PROFILE and
publishes the dashboard ingress, neither of which a scenario helm component can
do. Install it with labctl platform up chaos/chaos-mesh.
Prerequisites:
- Platform: ingress, monitoring/metrics, monitoring/grafana, chaos/chaos-mesh
- Apps: go-api
The drill: three checks start PENDING and only pass on the learner's work:
chaos-experiment-was-run (inject a failure), workload-survives-pod-loss
(scale out until the PDB reports disruptionsAllowed >= 1), and
blast-radius-contained (re-run the same experiment against the hardened
service and show the outage is gone). Measured on a k3d lab at 20 rps: a
pod-kill at one replica takes the request rate to zero for about ten
seconds; at three replicas the rate never leaves the floor.
Explore after activation:
- Chaos dashboard at
http://grafana.snowops.localhost/d/chaos-engineering - Chaos Mesh UI at
http://chaos.snowops.localhost - Load with
labctl traffic start --app <app> --profile browse --rps 20, never a curl loop - Run one experiment:
bash scenarios/chaos-engineering/scripts/inject.sh pod-kill --app <app> --namespace <namespace>(with no argument it lists what is available)
Traps this scenario documents:
minAvailable: 1on a one-replica Deployment can never be satisfied, so the PDB reportsdisruptionsAllowed 0/InsufficientPodsand blocks every node drain while doing nothing about involuntary failures. Scaling out is the fix.- The experiment gauge is
chaos_controller_manager_chaos_experimentswith lowercase phases (running,finished), notchaos_mesh_experiments; the experiment's own namespace arrives asexported_namespacebecause the scrape target'snamespacelabel wins. See R13. - go-api's
http_server_request_duration_secondsis handler time, so it stays flat at ~4ms under a 300msNetworkChaosdelay. Network faults are only visible client-side (curl -w time_starttransferthrough the ingress). - A
network-partitionleaves the pod1/1 Readywith perfect pod metrics while every request through the ingress fails. - Chaos Mesh's memory stressor takes stress-ng byte format (
256MB), not a Kubernetes quantity -256Miis rejected by the validating webhook. chaos-experiments.yamlis the one manifest the scenario never installs, on purpose: applying all six at once makes the blast radius unattributable. But the engine only renders templates in files it installs, so nothing resolves its{{.WorkloadName}}/{{.WorkloadNamespace}}-kubectl apply -fon it fails withnamespaces "{{.WorkloadNamespace}}" not found.scripts/inject.shrenders the binding and applies exactly one experiment by label.- A pod-kill outage is invisible at a one-minute resolution. The service is
gone for roughly ten seconds; sampled with
[15m:1m]the floor never reaches zero and a "did it stay up" check passes on a single replica. Use a 30s sub-interval. - Measuring blast radius must start at the hardening, not a fixed window back. The flatline on one replica was the first experiment working; counting it in the second measurement fails the learner for doing the drill correctly.
The client's view is now on the dashboard. Every original panel read the app's own metrics, which cannot record a request the app never received, so the two failure modes the scenario calls out as invisible really were. Two k6 panels (
k6_http_req_failed_rate,k6_http_req_duration_p99) show failures and latency as a caller experiences them, next to the app's own numbers.
Autoscaling Under Load (autoscaling-under-load)
Category: scalability
What it deploys:
- A Grafana dashboard (replicas vs RPS, p99 vs p50, tail amplification)
What you do: apply the KEDA ScaledObject yourself from the snippet in
labctl scenario info: declaring metric-driven autoscaling is the skill the
scenario exists to teach, then drive the spike and watch it react. Until you
do, verify reports those steps as PENDING rather than failed.
Prerequisites:
- Platform: ingress, monitoring/metrics, monitoring/grafana, autoscaling/keda
- Workload capabilities:
prometheus-metrics
Checks (6): KEDA operator ready, ScaledObject present, KEDA HPA created,
workload scaled above its floor, latency did not degrade (p99/p50 under the
MaxTailAmplification knob), no errors under load.
The scale check reads the autoscaler's replica count, not the Deployment's,
so a hand-run kubectl scale does not satisfy it.
None of the checks encodes how fast one runtime is, so the scenario runs
unchanged against another application - --app <name>.
Explore after activation:
- Drive the spike:
labctl traffic start --profile spike --rps 10 - Watch scaling:
kubectl -n <workload-ns> get hpa keda-hpa-<workload> -w - Verify post-spike:
labctl scenario verify autoscaling-under-load
Multi-Env Promotion (env-promotion)
Category: delivery
What it deploys:
- Three namespaces (
env-dev,env-staging,env-prod) each running the same workload pinned to an explicit image tag, plus anenv-metadataConfigMap recording the tag each environment declares
What you do: build a new immutable tag, deploy it to dev only, then promote
it forward with kubectl set image and kubectl rollout status, and roll a bad
promotion back with kubectl rollout undo. There is deliberately no
labctl env promote: promotion is those kubectl verbs, and labctl's job is to
build the three environments and grade the result
(R06).
- A Grafana dashboard (
/d/env-promotion) showing which tag each environment asked for, whether its rollout converged, and the ReplicaSet count that iskubectl rollout historyas a graph
Prerequisites:
- Platform: ingress, monitoring/metrics
- Workload capabilities: none beyond the base contract
Checks (9): three namespaces exist, the workload runs in each, a newer release reached prod, every environment's running image matches its declared tag (compared on the full image reference, not just the tag) and a rollback was exercised.
The rollback check grades the one trace a rollback leaves that rolling forward
cannot: prod serving a ReplicaSet older than the newest it has created. Rolling
forward always creates a new ReplicaSet; coming back re-activates an existing one
and bumps its revision. It does not care whether you used rollout undo or set
the image back by hand, because those are the same operation.
Explore after activation:
- Compare all three:
for e in dev staging prod; do curl -s <workload>-$e.<domain>/version; done - Promote:
kubectl -n env-staging set image deploy/<workload> <workload>=<workload>:v1.1.0 - Then the recovery drill: ship prod a tag nobody built
(
<workload>:v9.9.9), verify (spec says v9.9.9, the rollout never converges, the pods still serve the old tag, andverifyreports all three), thenkubectl -n env-prod rollout undo deploy/<workload>
Read
image_spec, notimage: the lab builds each version as bothvX.Y.Zand:latest, so those tags share a digest and the kubelet reports whichever it resolved - commonlydocker.io/library/<workload>:latestfor a pod that correctly requested<workload>:v1.1.0. The consistency check compares pod.specfor the same reason.
Teardown removes the versioned images it built and deliberately leaves
:latestalone - that is the tag every other scenario's workload runs on. Copies already imported into the cluster's image store stay there until the node is replaced; the teardown says so rather than implying a clean slate.
Mesh Traffic Management (mesh-traffic-management)
Category: networking · Mesh provider: Istio
What labctl stages:
- The namespace enrolled into the mesh (
istio-injection=enabled) and its deployments restarted, so pods actually get a sidecar - Two versions of the workload (v1, v2) behind one Service, distinguished by
APP_VERSIONso/versionreports which subset served a request - A
DestinationRulemapping theversionlabel to subsets - A meshed k6 load client, so the mesh has traffic to shape and report
- A Grafana dashboard (
mesh-traffic): split by version, v2 share, latency percentiles, mTLS coverage
What you do: all three objectives. Nothing installs the routing for you:
apply the weighted VirtualService, then the header-matched latency fault, then
the STRICT PeerAuthentication. Until you apply the first one, the Service
round-robins both subsets at roughly 50% and the split check fails.
Prerequisites:
- Platform: ingress, mesh/istio, monitoring/metrics
- Apps: the bound workload
Checks (6): istiod ready · every canary pod carries an istio-proxy sidecar
· v2's share stays above a 2% floor · and below 30% (versus ~50% for unrouted
round-robin) · v2 p99 latency shows the injected delay · an unmeshed plaintext
client is refused.
The two share checks are the bounds of one band, so each names a distinct failure: rolled back to weight 0, and not routed at all.
The last four are pending - they are the drill, not the setup. The sidecar
check is a guard: without it, a namespace that was never enrolled grades as a
complete success while every routing rule sits inert.
Explore after activation:
- Grafana → the
mesh-trafficdashboard: v2 share should fall from ~50% to ~10% the moment yourVirtualServicelands - Promote the canary by shifting the weights (90/10 → 50/50 → 0/100) and re-applying
- Confirm mTLS:
kubectl -n <workload-ns> get peerauthentication <app>-mtls -o jsonpath='{.spec.mtls.mode}'
Why the fault is header-matched. An Istio fault applies to a whole
HTTPRoute, so inside one weighted route there is no way to delay v2 and not v1. Matchingx-canary: alwaysfirst gives the canary traffic a route of its own, which is also how you would rehearse a slow release against opted-in testers. Istio takes the first match inspec.http, so that route must sit above the weighted one.
Event-Driven Architecture (event-driven-arch)
Category: data
The exercise: a producer/consumer flow through Kafka is already running, and producers have been ramped to five times what the consumer group can process, so orders are piling up. Diagnose the backlog, then restore throughput by scaling the group to the topic's partition count, and no further.
What it stages:
- An
ordersKafkaTopic (3 partitions) on the Strimzilab-kafkacluster - A producer and a consumer group (
order-processors) built from the Apache Kafka console tools. The consumer sleeps 50ms per message, standing in for the work a real order processor does; that cost is what bounds one consumer to ~18 orders/second - Stage 2: three producers at 5× rate (~29 orders/second), so lag climbs at roughly 11/s and a single consumer can never recover
- PodMonitors for both Kafka metric sources, and two Grafana dashboards
What the learner does: read the per-partition backlog from
kafka-consumer-groups.sh and the lag dashboard, then
kubectl scale deployment/orders-consumer --replicas=3 and watch it drain. The
producers stay ramped throughout - stopping them is graded as not a fix.
Prerequisites:
- Platform: data/kafka, monitoring/metrics, monitoring/grafana
- Apps: none. This scenario runs entirely against Kafka and does not touch the bound workload.
Checks (9): Kafka cluster ready, orders topic ready, producers still ramped, broker metrics scraped, consumer-lag metrics scraped, consumer group at full width (three members, sustained), consumers within the partition ceiling, backlog was observed, backlog drained.
Three of those carry the lesson. consumer-group-at-full-width reads Kafka's own
kafka_consumergroup_members rather than pod count, because a consumer pod can
be Ready and still not be in the group; it uses min_over_time so the members a
previous run leaves behind during termination cannot satisfy it.
consumers-within-partition-ceiling fails a learner who scales past three - a
partition is served by exactly one consumer in a group, so the extras sit idle
and add rebalance churn. And backlog-was-observed gates backlog-drained,
which is otherwise green at t=0 when nothing has piled up yet.
Traps this scenario encodes:
- A consumer that only prints its input absorbs any load the lab can generate, and the lag exercise becomes unperformable. The per-message cost is load-bearing.
- A consumer group outlives both the Deployments that joined it and the topic it
read. Left behind, the next activation reports negative lag for several
scrapes - committed offsets from the old topic ahead of the new one's log end.
scripts/reset-consumer-group.shdeletes the group on activation and teardown, and the drained check clamps at zero as a second line of defence. - Over-scaling puts the group into continuous rebalance, and
kafka-consumer-groups.sh --describethen prints an empty table. That reads as "the group vanished"; it has not.
Explore after activation:
- Diagnose:
kubectl -n kafka exec lab-kafka-dual-role-0 -- bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group order-processors - Drain it:
kubectl -n kafka scale deployment/orders-consumer --replicas=3(or add a KEDA Kafka-lagScaledObjectwithmaxReplicaCount: 3)
Secrets Management & Rotation (secrets-management)
Category: security
What it deploys:
- Two workloads consuming the same synced Secret
go-api-secrets:secret-consumermounts it as a file (/etc/api/api-key),env-consumertakes it as an env var: the control group - A baseline value seeded at Vault
secret/go-api - A Secret Rotation Grafana dashboard (ESO sync calls, readiness, Vault API rate)
The Vault->ESO->Secret wiring itself belongs to the secrets/external-secrets
platform component; this scenario mounts what that component publishes and does
not re-create or remove it.
Prerequisites:
- Platform: secrets/vault, secrets/external-secrets, monitoring/metrics
- Apps: go-api
Checks (9): ESO controller ready, ExternalSecret ready, ESO reports synced (promql on ESO's own Ready condition, no series means Prometheus is not scraping ESO, a series pinned at 0 means ESO is telling you it cannot sync), both consumers running, then four pending drill steps: rotation performed (the value in Vault moved off the baseline), rotation reached pod (the file in the running container equals Vault), no redeploy needed, env consumer did not see it (the env-var pod is still on the old value, which is the lesson).
no-redeploy-needed is graded against a checkpoint the activation records,
the UID of the consumer pod and the Deployment's generation at the moment the
drill began, so passing means this pod, the one that was already running,
picked up a value it was never started with. Grading a literal
observedGeneration == 1 instead would pass a pod that had been deleted and
recreated, since deleting a pod does not bump the Deployment's generation.
Run the drill:
- Watch the pod read the file:
kubectl -n <workload-ns> logs -l app=secret-consumer -f - Rotate in Vault:
kubectl -n vault exec vault-0 -- sh -c 'VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN=root vault kv put secret/go-api api-key=rotated-v2' - Watch the new value appear in the log with no restart (~90s), then compare:
kubectl -n <workload-ns> exec deploy/secret-consumer -- cat /etc/api/api-keyagainstkubectl -n <workload-ns> exec deploy/env-consumer -- sh -c 'echo $API_KEY' - Grade it:
labctl scenario verify secrets-management
Two hops, not one: ESO refreshes the Secret every 15s, then the kubelet refreshes the mounted file on its own cycle - end to end is up to ~90s, so a PENDING
rotation-reached-podfor a minute is normal.
Env vars are the trap: the kubelet cannot rewrite a running process's environment, so
env-consumerstays on its start-time value until something rolls it. Mount a secret as a file when you want rotation to be free. (The key is bound withsecretKeyRef, notenvFrom:api-keyis not a valid env var name andenvFromwould skip it silently.)
ESO backs off, and re-seeding alone will not wake it: dev-mode Vault keeps its KV in memory, so a
vault-0restart empties it and every ESO read fails. The failures back the controller off exponentially (within a few hours it is retrying hours apart, not on its 15srefreshInterval), so putting the value back changes nothing until a reconcile is due. Break the backoff with ESO's own idiom, which activation now does for you:kubectl -n <workload-ns> annotate externalsecret <workload>-secret force-sync=$(date +%s) --overwrite
Reading ESO's metrics: the
namespacelabel onexternalsecret_*is the controller's namespace (external-secrets); Prometheus moved the real one toexported_namespace. Select onname, or onexported_namespace, never onnamespace. And a successful rotation looks like nothing on these metrics: ESO reconciles every 15s whether the value moved or not, so the dashboard is there for the failure shape. The last hop into the container's file is not instrumented at all; watch that in the consumer's logs.
No secrets in git: the Vault dev token comes from
VAULT_DEV_ROOT_TOKEN(defaultroot).
Day-2 Drill: Node Drain Under Load (node-drain-drill)
Category: operations
What labctl stages:
- The shape the drill needs: the workload scaled to 3 replicas, and the nodes currently holding its pods recorded so the drain can be graded
- A Node Drain Drill Grafana dashboard (
/d/node-drain-drill): success rate, cordoned nodes, pod placement per node, ready vs desired replicas
What you do: write and apply the PodDisruptionBudget, start traffic, then
cordon and drain a worker node with kubectl and put it back. Nothing performs
the drain for you - draining is the transferable skill.
Prerequisites:
- Platform: ingress, monitoring/metrics
- Apps: the bound workload
- A multi-node cluster (k3d defaults to 2 agents;
AGENTS=2 labctl runtime up)
Checks (8): workload ≥2 ready (guard) · PDB present · exactly one PDB selects the workload · PDB tracks ≥2 healthy pods · traffic is flowing · a node that held the workload now holds none · success rate ≥ 99.5% across the drain (promql) · no node left cordoned.
Run the drill:
- Apply your PDB:
labctl scenario render node-drain-drill manifests/baseline.yaml --app <app> | kubectl apply -f - - Start traffic:
labctl traffic start --profile steady --rps 20 - See placement, then
kubectl cordon <node>andkubectl drain <node> --ignore-daemonsets --delete-emptydir-data kubectl uncordon <node>and grade:labctl scenario verify node-drain-drill
Two traps this drill will walk you into, on purpose. Only one PDB may cover a pod. The eviction API refuses a pod matched by two, and
kubectl drainthen fails partway through with an error naming neither -chaos-engineeringandcluster-upgrade-drillboth ship a budget for the same workload, so leaving one active collides. A check catches it before you drain. Node-local storage does not move. Prometheus, Loki and Alertmanager uselocal-pathvolumes, and a local PV is pinned to its node by node affinity, so draining that node leaves themPendinguntil you uncordon. That is why the drain is graded from cluster state rather than from a metric: the node you drain may be the one running the observer.
Day-2 Drill: Rolling Cluster Upgrade Under Load (cluster-upgrade-drill)
Category: operations
What it deploys:
- The shape the drill needs: go-api scaled to 3 replicas. The
PodDisruptionBudgetis yours to write and apply: that is objective one - A Cluster Upgrade Drill Grafana dashboard (
/d/cluster-upgrade-drill) plotting nodes by kubelet version, go-api pods per node, cordoned nodes, the PDB's healthy-pods vs disruptions-allowed, and the success rate being graded
Prerequisites:
- Platform: ingress, monitoring/metrics
- Apps: go-api
- Runtime: k3d (multi-node)
Checks (8): PDB present, PDB protects >=2 healthy, workload >=2 ready, all nodes Ready and uncordoned (script), no node left behind on the old version (script), workload survived the roll and is still spread across nodes (script), traffic was actually flowing (promql), success rate >= 99% across the upgrade window (promql).
Run the drill:
- Start traffic:
labctl traffic start --profile steady --rps 20 - Apply your PDB:
labctl scenario render cluster-upgrade-drill manifests/baseline.yaml --app <app> | kubectl apply -f - - Per worker node, cordon and drain it yourself:
kubectl cordon <node>thenkubectl drain <node> --ignore-daemonsets --delete-emptydir-data --timeout=120s - Then replace the drained node:
TARGET_K3S_VERSION=<tag> bash scenarios/cluster-upgrade-drill/scripts/roll-node.sh <node> - Grade it:
labctl scenario verify cluster-upgrade-drill
Honest scope: k3d has no in-place node upgrade.
roll-node.shreplaces one already-drained agent node with a fresh one on the target k3s image - a faithful rolling worker upgrade, and the same operation a managed node group performs. It refuses to touch a node you have not drained, refuses a downgrade, and refuses a kubelet more than three minor versions behind the API server. The control-plane node is left as-is; managed clusters upgrade it first.
Node replacement destroys local storage. Any local-path PersistentVolume pinned to a node you replace is lost and its claim sits Pending forever - in this lab that routinely strands Prometheus itself, so the drill can no longer be graded.
roll-node.shwarns before deleting such a node, andscripts/reclaim-stranded-pvcs.sh(tryDRY_RUN=truefirst) releases the dead claims so their StatefulSets rebind. A drain also evicts the k6 traffic Job, which does not restart itself - checklabctl traffic statusafter each roll. See R04 §8 for the full set of node-replacement traps.
Rolling a node used to break the cluster's load balancer - permanently. k3d's
serverlbholds its nginx upstreams as a fixed list of node names captured at cluster creation.roll-node.shreplaces a node under a new name (agent-0→agent-0-0→agent-0-0-0) and nothing updated that list. It is invisible while the cluster keeps running, because nginx is already up, but on the next restart confd re-renders the config, nginx fails validation on the missing host ([emerg] host not found in upstream "k3d-snowops-agent-0:443") and the load balancer never starts. One dead upstream takes down the whole listener set including the 6443 mapping the kubeconfig points at, so the entire lab, API server included, is unreachable. Observed for real on 2026-09-09 after a Docker restart.roll-node.shnow repoints the load balancer and restarts it; the rewrite matches whole lines, because node names are prefixes of one another.
Checks (8). Four are the drill and stay PENDING until it is done:
no-node-left-behind, workload-survived-the-roll, traffic-is-flowing and
availability-held-during-upgrade.
A cluster nobody touched passes every version test there is. One distinct kubelet version across every node is what "finished" and "never started" both look like, so
no-node-left-behindused to be green before the drill began. Activation now records the workers by UID and the check refuses to call the roll finished while any of them is still the same node. UID, not name: k3d reuses a node's name whenever it can, so a genuinely rolled worker usually comes back called exactly what it was.
The availability grade was a ratio of two rates, which reads 1.00 on an idle cluster. The scenario's headline number was therefore green on an untouched lab - sitting next to a
traffic-is-flowingcheck correctly reporting there was nothing to measure. It is now a script that requires a roll to have started, requires real load, and measures from the roll rather than a fixed ten minutes back. It also clamps the window to what Prometheus can actually see, because this drill can destroy Prometheus's own volume.
roll-node.shfailed whenever k3d reused the node name. It identified the replacement by diffing node names before and after, so when the name came back unchanged the diff was empty and it died with "the replacement node did not register with the API server" - about a node that had registered perfectly. It now diffs UIDs, polls for registration instead of reading once, and no longer swallows thek3d node createerror behind|| true.
The stranded-PVC recovery did not recover the common case. It looked for volumes pinned to a node that no longer exists. When the name is reused the affinity still matches, the pod schedules happily and then cannot mount -
MountVolume.NewMounter initialization failed ... path ... does not exist: and the script reported "No stranded volumes" while three pods sat stuck for ever. It now also detects that case from the kubelet's own FailedMount events. Verified live: it found and released Prometheus, Alertmanager and Loki after a real roll.
Rolling the node that holds your observability stack is part of the lesson. Prometheus, Loki and Alertmanager use
local-pathvolumes pinned to a node; if that node is the one you roll, their data goes with it. Runreclaim-stranded-pvcs.sh(withDRY_RUN=truefirst), and expect the metrics history, not the lab, to be gone.
A drain will block on someone else's PodDisruptionBudget. Confirmed live: the drain stalled for the full five-minute timeout on single-replica
istiod, whose PDB forbids evicting its last pod.kubectl get pdb -A -o wideand look for ALLOWED DISRUPTIONS 0. This is the drain working correctly and the most common reason a real node roll stalls.
Day-2 Drill: Namespace Backup & Restore (backup-restore-drill)
Category: operations
What it deploys:
- A
restore-markerConfigMap in the workload namespace carrying a token minted at activation. The token lives in exactly two places, the cluster and your archive, which is what makes a restore distinguishable from a re-create - A
data-writerDeployment mounting a PVC (restore-data) that persists a randomboot-idon its PersistentVolume, so PV-data loss is measured, not just described - A Grafana dashboard (
/d/backup-restore) plotting the object count through the loss and the restore, and the age of the volume behind the claim, which drops to zero the moment a restore provisions a replacement
Prerequisites:
- Apps: the bound workload
jq, which does the scrubbing.labctl doctorlists it alongside the rest of the toolchain; it is optional there because the lab stands up without it
Checks (8): namespace exists, workload running, data-writer running, backup
archive usable, loss was observed, restore round-tripped, restore PVC
present, PV data loss demonstrated. The last five are pending: they render
as PENDING, not FAIL, until the matching drill step is done. Three of them grade
something live state alone cannot see:
- usable, not merely present: the archive is parsed, and must contain the
restore marker with the current token. An empty file created by
touchfails. - loss was observed: nothing in a healthy namespace distinguishes "I restored it" from "I never broke it", so the loss has to be witnessed. Every verify that finds the marker missing records it; that run is the evidence.
- round-tripped: the live marker's token must equal the archive's.
labctl scenario up --forcemints a new token and turns this red, because re-staging is not a restore.
Run the drill (real kubectl - the commands you would use on a live cluster):
- Back up:
bash scenarios/backup-restore-drill/scripts/backup.sh <workload-ns>(read it - it iskubectl get … -o json | jq <scrub>). It also records the data-writer'sboot-id: the fingerprint of the data the archive does not contain. - Simulate loss:
kubectl -n <workload-ns> delete configmap restore-marker - Verify while it is lost: that run is what grades the loss
- Restore:
kubectl apply --server-side --force-conflicts -f ~/.snowops/state/<cluster>/backups/<workload-ns>-latest.json - Then the hard half:
kubectl -n <workload-ns> delete deploy data-writer && kubectl -n <workload-ns> delete pvc restore-data, restore again, and runobserve-pv-data.sh: theboot-idchanged.
Manifest-level backup: archives round-trip Kubernetes objects, not PersistentVolume data. Delete the ConfigMap and the
boot-idsurvives a restore; delete the claim and it comes back bound to a brand-new empty volume. The object round-trips, the data does not. For stateful data use a volume snapshot or Velero with restic.
Deleting the whole namespace also works,
restore.shrecreates it before applying, but the archive deliberately skips Helm release Secrets, so the workload comes back as loose objects with no release behind them. Finish withlabctl app deploy <workload>to put it back under management.
Teardown removes the archives along with the marker. They are keyed to a token that teardown destroys, so a kept archive would only grade red next time.
Cost & Capacity: Right-Sizing (cost-right-sizing)
Category: cost
The exercise: the bound workload arrives reserving 2000m CPU and 1Gi of memory - roughly forty times what it uses. Drive real traffic, read the gap between reserved and used, then right-size the requests.
What it stages:
inflate.sh, which puts the workload into the over-provisioned state with onekubectl set resources: the exact inverse of the fix, and records the sizing the workload arrived with in an annotation so teardown can restore it. It mutates the running Deployment directly rather than via a Helm upgrade, so it works no matter how the workload was deployed.- A ServiceMonitor for OpenCost, so its cost figures reach Prometheus instead of living only inside its own UI
- A dashboard plotting requests against usage, CPU efficiency, OpenCost allocation and throttling
Checks (8): four guard the environment (workload running, OpenCost running,
cost metrics scraped, /health returns 200). Four grade the drill: the workload
is under real load, the CPU request is right-sized, the memory request is
right-sized, and the workload is not being throttled.
Grading has two sides, and that is the point. A request over the waste
ceiling fails, and so does one under the peak the workload actually reached.
An upper bound alone makes --requests=cpu=1m a full-marks answer to a scenario
about sizing requests correctly.
Traps this scenario encodes:
- Sizing an idle workload is not right-sizing. With no traffic the measured
peak collapses to a couple of millicores and an absurd request clears the
floor.
workload-under-loadis graded before the sizing checks for that reason. - The floor is a peak over a window, not a spot reading. It comes from
Prometheus, not
kubectl top: a sample taken while the workload happened to be quiet blesses a request that throttles the moment traffic returns, and metrics-server is briefly blind to a pod that has just rolled - exactly when the learner re-runs verify after right-sizing. - Requests bill; limits throttle.
workload-not-throttledcatches the learner who cuts both to the same small number, capping the workload below what it needs rather than saving anything. - Prometheus overwrites a target's exported labels. OpenCost reports one
series per container in the cluster, carrying that container's own namespace
and pod labels. Without
honorLabels: trueevery cost series comes back labellednamespace="opencost"and every per-namespace cost query returns nothing while the data is plainly there. - A dotted annotation key needs escaping in jsonpath.
snowops.net/...reads as nested fields unless the dots are escaped, so the restore read nothing and set the shared workload's requests and limits to zero on teardown.
Prerequisites:
- Platform: cost/opencost, monitoring/metrics, ingress
- Apps: the bound workload
cost/opencostneedsmonitoring/metrics(Prometheus + kube-state-metrics) to compute allocation, without it the OpenCost API returns no cost data.scenario upwarns if a platform prerequisite is missing and prints thelabctl platform up <component>command to install it.
The drill:
labctl scenario up cost-right-sizing: inflates the requests; the two sizing checks report pendinglabctl traffic start --profile steady --rps 25: without load the peak is meaningless and the floor cannot bite- Read the gap on
http://grafana.snowops.localhost/d/cost-right-sizing, and the cost in the OpenCost UI athttp://opencost.snowops.localhost - Right-size above the observed peak and under the ceiling:
kubectl -n <workload-ns> set resources deployment <workload> --requests=cpu=<peak+headroom>,memory=<peak+headroom> labctl scenario verify cost-right-sizing: all checks passlabctl scenario down cost-right-sizingrestores the sizing the workload arrived with; it is shared with every other scenario
k3d note: no real billing API - OpenCost uses on-prem pricing defaults (~$0.048/CPU-hr). Cost numbers are relative; the before/after contrast is real.
Full YAML reference: scenario schema. Sharing scenarios from your own repository, and the security implications of doing so, are covered there too.