Files
clean-architecture-backend-…/infra/redis-lab

Disposable Redis qualification lab

This directory owns the lifecycle contract for the isolated three-node k3s lab. It does not contain Redis workloads, credentials, certificates, or qualification evidence.

Fixed topology

Instance CPU Memory Disk Role
ca-redis-lab-server 2 3G 12G k3s server
ca-redis-lab-agent-1 2 2560M 12G k3s agent
ca-redis-lab-agent-2 2 2560M 12G k3s agent

The lab uses pod CIDR 10.52.0.0/16, service CIDR 10.53.0.0/16, and context ca-redis-lab. versions.env pins the k3s version and Multipass image. Traefik and ServiceLB are disabled.

Safety model

All state, rendered cloud-init, kubeconfigs, tokens, and raw observations are mode-restricted beneath the ignored src/build/redis-lab directory. Every canonical ancestor from the repository root through src/build/redis-lab, plus runtime children, is validated before observation or mutation; a symlink or real-path escape fails closed. The lifecycle never exports KUBECONFIG, merges a kubeconfig, or writes the user's default kubeconfig.

Host observation and lab access deliberately use different explicit targets:

  • host read-only queries copy the default kubeconfig into src/build/redis-lab/observations/host-kubeconfig and use its unchanged original context;
  • lab read-only queries use src/build/redis-lab/kubeconfig and exact context ca-redis-lab.

This split preserves the host context identity while ensuring no kubectl call relies on an implicit target. Host kubectl mutations are not part of the lifecycle. The observation-only host copy is removed after fingerprint and CIDR observation on success and every handled failure path.

Each exact name gets a private mode-0600 rendered cloud-init file beneath src/build/redis-lab/cloud-init. It writes only the non-secret ownership marker RUN_ID|VM_NAME to /var/lib/ca-redis-lab/ownership as root:root mode 0600; launch uses only that rendered file.

Each name is then atomically reserved as PENDING in run.state before its bounded launch. The state starts with an exact per-run identity, and each PENDING/CREATED/RECONCILE entry carries that same identity. A successful launch becomes CREATED only after a bounded multipass exec <name> -- sudo cat /var/lib/ca-redis-lab/ownership returns the exact marker. Timeout, launch error, missing/foreign marker, signal, promotion failure, or uncertain cleanup enters RECONCILE.

Cleanup transitions a recorded entry to RECONCILE, bounded-polls the exact instance and marker, and issues multipass delete --purge <exact-name> only after the marker matches. It atomically removes only an entry whose delete succeeded. A late-created matching instance is deleted; an absent instance, unreadable marker, mismatched/foreign marker, or failed delete is retained as a tombstone and fails closed without an unproven delete. Existing instance-bearing state blocks a new preflight, up, or run; a rejected new run does not clean the prior run, and down is the retry/reconciliation entry point. Existing allowlisted names without owned state cause up to stop before reservation/launch and are never adopted or deleted. Wildcards, --all, global purge, and discovered-instance deletion are forbidden.

The lifecycle lock is nonblocking and exclusive. External children close its descriptor by default, including detached infrastructure descendants and run -- commands; only lock acquisition retains descriptor 9.

Multipass list/launch/info/exec/transfer/delete, installation/join, and kubectl calls have fixed time bounds. up succeeds only after the exact server and two agents all report Ready=True within the bounded poll budget; incomplete or not-ready inventory enters marker-proven run-owned cleanup.

The k3s runtime is amd64-only and fail-closed in this slice. versions.env pins the immutable release URL and exact SHA-256 for v1.33.3+k3s1. The lifecycle performs a bounded host download, verifies the digest, transfers the binary to each exact VM, verifies the transferred digest and reported binary version inside each VM, and only then installs/starts it. It does not execute a network installer or a curl | sh pipeline.

The generated lab kubeconfig is accepted only in the pinned single-cluster/single-context/ single-user block grammar. A tracked AWK state machine has one explicit transition for every allowlisted line and publishes no output until the complete document reaches its exact final state. It rejects missing, duplicate, reordered, unknown, whitespace-altered, quoted, tagged, or explicit keys; anchors, aliases, merge keys, tabs, CRLF, document markers, trailing content, and all flow collections except exact preferences: {}. Only the exact cluster/context/user identity, current-context, and loopback API server are rewritten. CA data, client certificate/key data, and an optional canonical namespace are byte-preserved.

Rendering uses a same-directory kubeconfig.next, applies mode 0600, and replaces the destination only after render and permission success. The renderer must be a readable regular non-symlink file at its canonical tracked path, and both destination paths are protected by the runtime symlink contract. Renderer, permission, or move failure removes both candidate and destination, performs no lab kubectl, and enters exact marker-proven current-run cleanup.

Assigned Service ClusterIPs cannot prove the host service CIDR. When a host kubeconfig exists, callers must supply one or more canonical, comma- or space-separated IPv4 CIDRs through REDIS_LAB_HOST_SERVICE_CIDRS. Missing, malformed, or overlapping input fails before launch:

REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 \
  infra/redis-lab/bin/redis-lab preflight

Commands

The real lifecycle is for a trusted local or dedicated runner only:

REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 infra/redis-lab/bin/redis-lab preflight
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 infra/redis-lab/bin/redis-lab up
infra/redis-lab/bin/redis-lab down
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 infra/redis-lab/bin/redis-lab run -- command
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 \
  infra/redis-lab/bin/redis-lab run --retain-on-failure -- command

run establishes its cleanup obligation before entering the inner up, keeps it through the post-up/pre-command handoff and user command, then tears down after command success or failure and compares the canonical pre/post host fingerprints after cleanup. A successful direct up retains the lab by design. Local --retain-on-failure intentionally leaves the recorded lab for diagnosis and skips an isolation-success claim; CI=true rejects that option before launch.

The blocking contract is VM-free:

cd src
./gradlew :adapter:outbound:cache-redis:redisLabContractTest --console=plain

It injects fake infrastructure commands. Hosted CI must run only this contract, never the real lab. The contract executes a copied lifecycle in src/build/redis-lab-contract/repository, seals PATH to explicit fakes/safe wrappers, and compares a byte-level snapshot proving it did not modify the real repository's src/build/redis-lab. It also exercises direct/run signal cleanup, rendered-child symlink rejection, successful and late-create marker proof, absent/foreign-marker tombstones, second-run state preservation, CREATED cleanup uncertainty, rejected-run preservation of prior CREATED and RECONCILE state, the post-up/pre-command signal handoff, the canonical kubeconfig mutation matrix, missing/symlinked renderer rejection, fail-closed .next/permission/move publication, and infrastructure/user background-child lock non-inheritance. This is deterministic fake-runtime evidence only; it is not live Multipass, k3s, kubectl, network, or host-isolation qualification.