2026-06-09 08:45:33 +00:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# Idempotent machine-wide host base for the devvm Claude Code Workstation.
|
|
|
|
|
# Run as root. Sets up ONLY machine-wide state: the apt toolset, node + claude-code,
|
|
|
|
|
# kubelogin, the ENFORCED managed Claude config, and /etc/skel defaults (launcher,
|
|
|
|
|
# tmux UX, and live config-inheritance symlinks into the shared config base).
|
|
|
|
|
#
|
|
|
|
|
# PER-USER provisioning (accounts, per-tier groups, kubeconfig, secrets, infra
|
|
|
|
|
# clone) lives in t3-provision-users.sh — NOT here. Safe to re-run.
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
|
# The shared config base every user inherits from (live, chezmoi-versioned).
|
|
|
|
|
# Coupled to the admin's home today; override to relocate to a neutral path.
|
|
|
|
|
CONFIG_BASE="${WORKSTATION_CONFIG_BASE:-/home/wizard/.claude}"
|
|
|
|
|
[[ $EUID -eq 0 ]] || { echo "setup-devvm.sh: must run as root" >&2; exit 1; }
|
|
|
|
|
log() { echo "[setup-devvm] $*"; }
|
|
|
|
|
|
2026-06-20 20:22:05 +00:00
|
|
|
# 1) apt toolset (declarative manifest; full-line AND inline comments stripped).
|
|
|
|
|
# Passing an inline comment through makes apt treat the whole remainder as part
|
|
|
|
|
# of the package name (e.g. "golang-go # builds ..."), breaking every rebuild.
|
|
|
|
|
mapfile -t PKGS < <(sed -E 's/[[:space:]]+#.*$//' "$HERE/packages.txt" | grep -vE '^[[:space:]]*(#|$)')
|
2026-06-09 08:45:33 +00:00
|
|
|
log "apt: ensuring ${#PKGS[@]} packages present"
|
|
|
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
|
|
|
apt-get update -qq
|
|
|
|
|
apt-get install -y "${PKGS[@]}" >/dev/null
|
|
|
|
|
|
2026-06-15 17:12:05 +00:00
|
|
|
# 2) node >= 18 — needed for the t3 CLI (npm-global, below). NOT for claude-code:
|
|
|
|
|
# claude-code is the per-user NATIVE install (the recommended, self-updating
|
|
|
|
|
# ~/.local/bin/claude), provisioned per user by t3-provision-users
|
|
|
|
|
# (install_user_claude_native) and self-bootstrapped by start-claude.sh on first launch.
|
|
|
|
|
# We deliberately do NOT `npm install -g @anthropic-ai/claude-code` — npm/npx is not the
|
|
|
|
|
# recommended runtime, and a system-wide npm copy just shadows/duplicates the per-user
|
|
|
|
|
# native installs everyone auto-migrates to anyway.
|
2026-06-09 08:45:33 +00:00
|
|
|
need_node=1
|
|
|
|
|
if command -v node >/dev/null; then
|
|
|
|
|
[[ "$(node -v | sed 's/^v\([0-9]*\).*/\1/')" -ge 18 ]] && need_node=0
|
|
|
|
|
fi
|
|
|
|
|
if [[ $need_node -eq 1 ]]; then
|
|
|
|
|
log "node: installing NodeSource 22.x"
|
|
|
|
|
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - >/dev/null
|
|
|
|
|
apt-get install -y nodejs >/dev/null
|
|
|
|
|
fi
|
|
|
|
|
|
2026-06-15 17:20:03 +00:00
|
|
|
# 2a) ~/.local/bin on PATH for all LOGIN shells (machine-wide). The native claude install
|
|
|
|
|
# lives at ~/.local/bin; this guarantees login shells (SSH, etc.) find it regardless of
|
|
|
|
|
# whether the per-user native-installer rc edit ran. (The terminal launcher sets PATH
|
|
|
|
|
# itself, and t3-serve@.service hard-sets PATH in the unit.)
|
|
|
|
|
install -d -m 0755 /etc/profile.d
|
|
|
|
|
cat > /etc/profile.d/10-local-bin.sh <<'PROFILE_EOF'
|
|
|
|
|
# Native per-user installs (e.g. claude-code) live in ~/.local/bin — put it on PATH.
|
|
|
|
|
# Guarded so it never duplicates. Sourced by login shells (bash via /etc/profile; zsh
|
|
|
|
|
# login via /etc/zsh/zprofile -> /etc/profile).
|
|
|
|
|
case ":$PATH:" in
|
|
|
|
|
*":$HOME/.local/bin:"*) ;;
|
|
|
|
|
*) export PATH="$HOME/.local/bin:$PATH" ;;
|
|
|
|
|
esac
|
|
|
|
|
PROFILE_EOF
|
|
|
|
|
chmod 0644 /etc/profile.d/10-local-bin.sh
|
|
|
|
|
log "/etc/profile.d/10-local-bin.sh (~/.local/bin on PATH for login shells)"
|
|
|
|
|
|
t3: gated nightly tracker (replaces pinned enforcer) + drop timer Persistent
Phase 2 of "track t3 nightly, accept the risk, but make sure session auth works
and revert if it breaks". Rewrites the daily t3-autoupdate from a pinned-version
enforcer into a NIGHTLY TRACKER that gates every bump so a bad build self-heals
instead of repeating 2026-06-09:
- follows the t3@nightly npm dist-tag (T3_TRACK; T3_PIN still works as a hard
freeze; /etc/t3-autoupdate.freeze is the manual revert switch);
- downgrade-guard (the nightly tag is mutable — never move backward) + channel
sanity (target must be a -nightly. build);
- pre-bump per-user state.sqlite backup (online VACUUM INTO) BEFORE install, so
rollback is a restore not sqlite surgery;
- health-check now SEEDS a throwaway instance with a COPY of a real POPULATED
state.sqlite, exercising the forward MIGRATION (the actual 2026-06-09 failure
class) + the real mint->exchange->t3_session pairing handshake before trusting
a build. Scratch dir is on /var/tmp (disk), not the 2G tmpfs /tmp;
- canary rollout: restart idle instances ONE AT A TIME, verify pairing through
the real dispatch after each, and on the first failure roll back (binary +
that user's DB from the pre-bump backup) AND self-freeze so it can't re-flap
onto bad builds. Active-agent instances are deferred, never killed. Rollback
target is the recorded LAST-GOOD, not "whatever was installed";
- DRY_RUN mode (T3_DRY_RUN=1) previews the gate against a temp-prefix install —
validated: 0.0.28-nightly.20260616.571 PASSES the populated-DB migration gate.
timer: drop Persistent=true (a missed 04:00 must not fire a real bump on boot
mid-day with users active — a 2026-06-09 contributing factor).
setup-devvm.sh: install t3@nightly on fresh boxes (no state to break), in sync.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:08:12 +00:00
|
|
|
# 2b) t3 (the per-user coding surface) — GATED NIGHTLY TRACKER (2026-06-16; was pinned).
|
|
|
|
|
# t3 is pre-1.0 and ships breaking auth-schema + bootstrap-API changes (2026-06-09
|
|
|
|
|
# outage: a blind nightly auto-update broke pairing for ALL users). The daily
|
|
|
|
|
# t3-autoupdate now FOLLOWS t3@nightly but GATES each bump (populated-DB health-check
|
|
|
|
|
# + canary + auto-rollback + self-freeze) so a bad nightly self-heals. A fresh box has
|
|
|
|
|
# no user state to migrate or sessions to break, so install the current nightly
|
|
|
|
|
# directly; the gated tracker owns it thereafter. Keep T3_TRACK in sync with
|
|
|
|
|
# t3-autoupdate.sh. To freeze/revert: `touch /etc/t3-autoupdate.freeze`.
|
|
|
|
|
T3_TRACK="${T3_TRACK:-nightly}"
|
|
|
|
|
want_t3="$(npm view "t3@$T3_TRACK" version 2>/dev/null | tail -1)"
|
|
|
|
|
if [[ -n "$want_t3" && "$(t3 --version 2>/dev/null | awk '{print $NF}' | sed 's/^v//')" != "$want_t3" ]]; then
|
|
|
|
|
log "npm: installing t3@$T3_TRACK ($want_t3)"; npm install -g "t3@$want_t3" >/dev/null
|
2026-06-09 16:08:44 +00:00
|
|
|
fi
|
|
|
|
|
|
2026-06-09 21:41:31 +00:00
|
|
|
# 3) kubelogin (kubectl oidc-login) system-wide — NOT the apt 'kubelogin' (= Azure tool).
|
|
|
|
|
# PINNED (not 'latest/download') so two fresh boxes built weeks apart are byte-identical.
|
|
|
|
|
KUBELOGIN_VER="${KUBELOGIN_VER:-v1.36.2}"
|
2026-06-09 08:45:33 +00:00
|
|
|
if [[ ! -x /usr/local/bin/kubelogin ]]; then
|
2026-06-09 21:41:31 +00:00
|
|
|
log "kubelogin: installing int128/kubelogin $KUBELOGIN_VER"
|
2026-06-09 08:45:33 +00:00
|
|
|
tmp="$(mktemp -d)"
|
2026-06-09 21:41:31 +00:00
|
|
|
curl -fsSL -o "$tmp/kl.zip" "https://github.com/int128/kubelogin/releases/download/${KUBELOGIN_VER}/kubelogin_linux_amd64.zip"
|
2026-06-09 08:45:33 +00:00
|
|
|
( cd "$tmp" && { unzip -o kl.zip kubelogin >/dev/null 2>&1 || python3 -m zipfile -e kl.zip .; } )
|
|
|
|
|
install -m 0755 "$tmp/kubelogin" /usr/local/bin/kubelogin
|
|
|
|
|
ln -sf /usr/local/bin/kubelogin /usr/local/bin/kubectl-oidc_login
|
|
|
|
|
rm -rf "$tmp"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# 4) machine-wide ENFORCED Claude config (org claudeMd; top precedence; NO secrets)
|
|
|
|
|
install -d -m 0755 /etc/claude-code
|
|
|
|
|
install -m 0644 "$HERE/managed-settings.json" /etc/claude-code/managed-settings.json
|
|
|
|
|
log "managed-settings.json -> /etc/claude-code/ (enforced org claudeMd)"
|
|
|
|
|
|
|
|
|
|
# 5) /etc/skel for NEW accounts: launcher + tmux UX + live-inheritance symlinks.
|
|
|
|
|
# A symlink placed in /etc/skel is copied (as a symlink) into each new home by
|
|
|
|
|
# `useradd -m`, so new users' ~/.claude/{skills,rules,...} resolve to the shared
|
|
|
|
|
# base and pick up the admin's edits live. Secrets + hooks are per-user (written
|
|
|
|
|
# by the provisioner), NEVER symlinked here.
|
|
|
|
|
install -d -m 0755 /etc/skel
|
|
|
|
|
install -m 0755 "$HERE/skel/start-claude.sh" /etc/skel/start-claude.sh
|
|
|
|
|
install -m 0644 "$HERE/skel/tmux.conf" /etc/skel/.tmux.conf
|
|
|
|
|
install -d -m 0755 /etc/skel/.claude
|
|
|
|
|
for d in skills rules agents commands; do
|
|
|
|
|
[[ -d "$CONFIG_BASE/$d" ]] && ln -sfn "$CONFIG_BASE/$d" "/etc/skel/.claude/$d"
|
|
|
|
|
done
|
|
|
|
|
log "skel: launcher + tmux + inheritance symlinks (base=$CONFIG_BASE)"
|
|
|
|
|
|
2026-06-22 08:04:38 +00:00
|
|
|
# 6) BOOTSTRAP-deploy the roster-driven provisioner to /usr/local/bin (run hourly
|
|
|
|
|
# by t3-provision-users.timer). This seeds the binary on a fresh box; ongoing
|
|
|
|
|
# edits self-deploy from the repo on the next reconcile (the script's step 0),
|
|
|
|
|
# so a committed change no longer needs a manual setup-devvm.sh re-run to land
|
|
|
|
|
# (the gap that left the homelab-memory rollout undeployed for a day).
|
2026-06-09 08:45:33 +00:00
|
|
|
install -m 0755 "$HERE/../t3-provision-users.sh" /usr/local/bin/t3-provision-users
|
|
|
|
|
log "t3-provision-users -> /usr/local/bin/ (roster-driven)"
|
|
|
|
|
|
|
|
|
|
# 7) harden the admin's unlocked tree: it holds git-crypt-DECRYPTED secrets, so it
|
|
|
|
|
# must NOT be world-readable — only the admin + code-shared. Without this, ANY
|
|
|
|
|
# devvm user (even outside code-shared) could read decrypted secrets by path.
|
|
|
|
|
ADMIN_CODE="${ADMIN_CODE:-/home/wizard/code}"
|
|
|
|
|
if [[ -d "$ADMIN_CODE" ]]; then
|
|
|
|
|
chmod o-rx "$ADMIN_CODE"
|
|
|
|
|
log "hardened $ADMIN_CODE (o-rx — not world-readable)"
|
|
|
|
|
fi
|
|
|
|
|
|
2026-06-09 21:41:31 +00:00
|
|
|
# 8) /etc/t3-serve (per-user .env + dispatch config dir; also holds the staged tokens
|
|
|
|
|
# below) + shared service auth pulled from Vault. install -m alone does NOT create the
|
|
|
|
|
# parent dir, so a fresh box needs this mkdir before the token writes below.
|
|
|
|
|
install -d -m 0755 /etc/t3-serve
|
2026-06-09 14:05:44 +00:00
|
|
|
if command -v vault >/dev/null; then
|
|
|
|
|
export VAULT_ADDR="${VAULT_ADDR:-https://vault.viktorbarzin.me}"
|
|
|
|
|
# setup-devvm runs as root (no ~/.vault-token); borrow the admin's token to read Vault.
|
|
|
|
|
if [[ -z "${VAULT_TOKEN:-}" && -r /home/wizard/.vault-token ]]; then
|
|
|
|
|
VAULT_TOKEN="$(cat /home/wizard/.vault-token)"; export VAULT_TOKEN
|
|
|
|
|
fi
|
2026-06-20 20:10:40 +00:00
|
|
|
# 8a) Claude auth is deliberately NOT shared. Each roster user signs in with their own
|
|
|
|
|
# Enterprise identity; claude-auth-sync backs up only their OAuth object to an
|
|
|
|
|
# isolated Vault path. The provisioner mints its scoped Vault token when this admin
|
|
|
|
|
# VAULT_TOKEN is present.
|
2026-06-09 21:41:31 +00:00
|
|
|
# 8b) Shared Codex auth -> /opt/codex-shared/auth.json (the codex wrapper symlinks each
|
|
|
|
|
# user's ~/.codex/auth.json here). Previously a manual host change that did NOT survive
|
|
|
|
|
# a rebuild even though the Vault key existed — now reproducible from Vault.
|
|
|
|
|
if codex_auth="$(vault kv get -field=codex_shared_auth_json secret/workstation 2>/dev/null)"; then
|
|
|
|
|
getent group codex-shared >/dev/null || groupadd codex-shared
|
|
|
|
|
install -d -m 2770 -g codex-shared /opt/codex-shared
|
|
|
|
|
install -m 0660 -g codex-shared /dev/stdin /opt/codex-shared/auth.json <<<"$codex_auth"
|
|
|
|
|
log "staged /opt/codex-shared/auth.json (shared Codex auth)"
|
|
|
|
|
else
|
|
|
|
|
log "WARN: secret/workstation codex_shared_auth_json absent -> shared Codex auth not staged"
|
|
|
|
|
fi
|
workstation: per-user playwright browser MCP for all users, reproducible from git
Viktor asked that the playwright browser MCP be available for every devvm user
in every directory, with each user running their own server and multiple
concurrent sessions per user.
Before this, playwright was hand-set-up per user (~/.config/systemd/user/
playwright-mcp.service on 8931/8932/8933) and only wizard was actually wired —
emo's and anca's servers ran but their ~/.claude.json had no playwright entry,
so their Claude never connected. None of it was reproducible from git (units,
refresh script, and the Vault snapshot token lived only in user homes), so a
devvm rebuild would silently lose it.
This makes it reproducible and fixes the unwired users:
- roster_engine.py: sticky per-user PLAYWRIGHT_PORT (PLAYWRIGHT_BASE_PORT=8931,
allocated for every roster user incl. the admin), emitted in the derive JSON.
- scripts/workstation/playwright/: system-level TEMPLATE units
(playwright-mcp@.service + playwright-snapshot-refresh@.{service,timer},
User=%i — system manager, so no systemd --user / linger) + the refresh script.
@playwright/mcp pinned to 0.0.76 (avoids the @latest silent-fleet-roll
footgun, same rationale as T3_PIN).
- setup-devvm.sh: install the templates + script (9e); stage the chrome-service
snapshot bearer token from Vault to a root file (8c) — the hourly root
reconcile has no Vault token, mirrors the Claude OAuth staging in 8a.
- t3-provision-users.sh: install_playwright() (ALL tiers incl. admin) writes
PLAYWRIGHT_PORT, seeds the token if-absent, wires the user-scope ~/.claude.json
by running `claude mcp add` AS the user (clobber-proof + if-absent, so it fixes
existing/new/admin without rewriting a populated config), and enable --now's the
instances (idempotent, never restarts a running server). Also hardened the
section-1 *.env scan to skip the new playwright-*.env files (no T3_PORT -> grep
no-match would abort under set -e -o pipefail).
- Docs: chrome-service-snapshot runbook (new Provisioning section + system-unit
commands), multi-tenancy.md, and the 2026-06-07 plan Task 2.3.
Supersedes the hand-made per-user --user units (one-time idle-gated migration to
follow on the live host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:33:47 +00:00
|
|
|
# 8c) chrome-service snapshot bearer token -> root file the provisioner copies
|
|
|
|
|
# per-user (if-absent) to ~/.config/playwright/token, which the per-user
|
|
|
|
|
# playwright-snapshot-refresh reads. One token for all users (single shared
|
|
|
|
|
# warm profile, by design). 0600: the snapshot it fetches holds cookies.
|
|
|
|
|
if cs_tok="$(vault kv get -field=api_bearer_token secret/chrome-service 2>/dev/null)"; then
|
|
|
|
|
install -m 0600 /dev/stdin /etc/t3-serve/chrome-service-token <<<"$cs_tok"
|
|
|
|
|
log "staged /etc/t3-serve/chrome-service-token (playwright snapshot auth)"
|
|
|
|
|
else
|
|
|
|
|
log "WARN: secret/chrome-service api_bearer_token absent -> playwright snapshot refresh will 401"
|
|
|
|
|
fi
|
2026-06-09 14:05:44 +00:00
|
|
|
fi
|
|
|
|
|
|
workstation: setup-devvm.sh installs the systemd service layer (reproducible rebuild)
The t3 system units (t3-serve@, t3-autoupdate, t3-backup-state, t3-provision-users,
t3-dispatch) + the t3-dispatch Go binary + t3-mint + the sudoers grant were all
hand-scp'd and would NOT survive a fresh devvm. setup-devvm.sh now installs + enables
them: build-if-absent for the Go binary, visudo-validated sudoers (a malformed
/etc/sudoers.d file breaks all sudo), timers self-heal, t3-dispatch system account
created if absent. t3-serve@ stays a per-user template enabled by the provisioner;
the ttyd terminal-lobby chain ships from its own repo (viktor/terminal-lobby).
Verified: shellcheck clean, go build compiles, visudo parses the sudoers, units parse.
NOT run live (would re-assert apt/npm on the shared host) — exercised on next rebuild.
[ci skip]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:07:20 +00:00
|
|
|
# 9) service layer: install + enable the machine-wide systemd units (sources in
|
|
|
|
|
# infra/scripts/) so a rebuild reproduces them — previously hand-scp'd, they would
|
|
|
|
|
# NOT survive a fresh box. Per-user t3-serve@ INSTANCES are enabled by the
|
|
|
|
|
# provisioner; the ttyd terminal-lobby chain ships from its own repo
|
|
|
|
|
# (forgejo viktor/terminal-lobby, scripts/deploy.sh) — not duplicated here.
|
|
|
|
|
SCRIPTS="$HERE/.."
|
|
|
|
|
# 9a) scripts the units exec (t3-provision-users already deployed in section 6)
|
|
|
|
|
install -m 0755 "$SCRIPTS/t3-autoupdate.sh" /usr/local/bin/t3-autoupdate
|
2026-06-21 12:36:13 +00:00
|
|
|
install -m 0644 "$SCRIPTS/t3-safe-restart.sh" /usr/local/lib/t3-safe-restart.sh # sourced lib (t3-autoupdate + t3-migrate-idle)
|
|
|
|
|
install -m 0755 "$SCRIPTS/t3-migrate-idle.sh" /usr/local/bin/t3-migrate-idle
|
workstation: setup-devvm.sh installs the systemd service layer (reproducible rebuild)
The t3 system units (t3-serve@, t3-autoupdate, t3-backup-state, t3-provision-users,
t3-dispatch) + the t3-dispatch Go binary + t3-mint + the sudoers grant were all
hand-scp'd and would NOT survive a fresh devvm. setup-devvm.sh now installs + enables
them: build-if-absent for the Go binary, visudo-validated sudoers (a malformed
/etc/sudoers.d file breaks all sudo), timers self-heal, t3-dispatch system account
created if absent. t3-serve@ stays a per-user template enabled by the provisioner;
the ttyd terminal-lobby chain ships from its own repo (viktor/terminal-lobby).
Verified: shellcheck clean, go build compiles, visudo parses the sudoers, units parse.
NOT run live (would re-assert apt/npm on the shared host) — exercised on next rebuild.
[ci skip]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:07:20 +00:00
|
|
|
install -m 0755 "$SCRIPTS/t3-backup-state.sh" /usr/local/bin/t3-backup-state
|
|
|
|
|
install -m 0755 "$SCRIPTS/t3-mint" /usr/local/bin/t3-mint
|
2026-06-20 20:10:40 +00:00
|
|
|
install -m 0755 "$HERE/claude-auth-sync.sh" /usr/local/bin/claude-auth-sync
|
workstation: setup-devvm.sh installs the systemd service layer (reproducible rebuild)
The t3 system units (t3-serve@, t3-autoupdate, t3-backup-state, t3-provision-users,
t3-dispatch) + the t3-dispatch Go binary + t3-mint + the sudoers grant were all
hand-scp'd and would NOT survive a fresh devvm. setup-devvm.sh now installs + enables
them: build-if-absent for the Go binary, visudo-validated sudoers (a malformed
/etc/sudoers.d file breaks all sudo), timers self-heal, t3-dispatch system account
created if absent. t3-serve@ stays a per-user template enabled by the provisioner;
the ttyd terminal-lobby chain ships from its own repo (viktor/terminal-lobby).
Verified: shellcheck clean, go build compiles, visudo parses the sudoers, units parse.
NOT run live (would re-assert apt/npm on the shared host) — exercised on next rebuild.
[ci skip]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:07:20 +00:00
|
|
|
# 9b) t3-dispatch: unprivileged system account + compiled Go binary (build-if-absent)
|
|
|
|
|
id -u t3-dispatch >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin t3-dispatch
|
|
|
|
|
if [[ ! -x /usr/local/bin/t3-dispatch ]]; then
|
|
|
|
|
if command -v go >/dev/null; then
|
|
|
|
|
log "building t3-dispatch (Go)"; ( cd "$SCRIPTS/t3-dispatch" && go build -o /usr/local/bin/t3-dispatch . )
|
|
|
|
|
else
|
|
|
|
|
log "WARN: go absent -> cannot build t3-dispatch; install golang-go or deploy the binary"
|
|
|
|
|
fi
|
|
|
|
|
fi
|
homelab: v0.1 docs, distribution wiring, and version
Completes v0.1: documentation, build/install path, and version stamping.
- cli/VERSION (v0.1.0) stamped into the binary via ldflags.
- cli/README.md rewritten as the homelab overview (verbs + tiers, manifest,
build, the preserved legacy webhook use-cases).
- docs/adr/0004-0006: why homelab exists (grown in place from infra/cli, not a
separate repo), v0.1 scope + everything-allowed/tiers-recorded, and the
work/tf behaviour (native worktree entry, verification-gated auto-land,
presence-coupled apply).
- setup-devvm.sh builds cli/ -> /usr/local/bin/homelab each provisioning run
(t3-dispatch pattern), so every devvm user gets the current binary.
- AGENTS.md: discovery pointer under Common Operations.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 19:25:51 +00:00
|
|
|
# 9b2) homelab: unified infra-ops CLI (agent-facing verbs + the in-cluster
|
|
|
|
|
# infra-cli webhook image). Rebuilt from cli/ each run so it tracks the
|
|
|
|
|
# repo; version stamped from cli/VERSION. See cli/README.md + docs/adr/0004-0006.
|
|
|
|
|
if command -v go >/dev/null; then
|
|
|
|
|
_hl_src="$SCRIPTS/../cli"
|
|
|
|
|
_hl_ver="$(cat "$_hl_src/VERSION" 2>/dev/null || echo dev)"
|
|
|
|
|
log "building homelab CLI ($_hl_ver)"
|
|
|
|
|
( cd "$_hl_src" && go build -ldflags "-X main.version=$_hl_ver" -o /usr/local/bin/homelab . ) \
|
|
|
|
|
|| log "WARN: homelab CLI build failed"
|
|
|
|
|
else
|
|
|
|
|
log "WARN: go absent -> cannot build homelab CLI"
|
|
|
|
|
fi
|
workstation: setup-devvm.sh installs the systemd service layer (reproducible rebuild)
The t3 system units (t3-serve@, t3-autoupdate, t3-backup-state, t3-provision-users,
t3-dispatch) + the t3-dispatch Go binary + t3-mint + the sudoers grant were all
hand-scp'd and would NOT survive a fresh devvm. setup-devvm.sh now installs + enables
them: build-if-absent for the Go binary, visudo-validated sudoers (a malformed
/etc/sudoers.d file breaks all sudo), timers self-heal, t3-dispatch system account
created if absent. t3-serve@ stays a per-user template enabled by the provisioner;
the ttyd terminal-lobby chain ships from its own repo (viktor/terminal-lobby).
Verified: shellcheck clean, go build compiles, visudo parses the sudoers, units parse.
NOT run live (would re-assert apt/npm on the shared host) — exercised on next rebuild.
[ci skip]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:07:20 +00:00
|
|
|
# 9c) sudoers: t3-dispatch may run ONLY t3-mint as root. A malformed file in
|
|
|
|
|
# /etc/sudoers.d breaks ALL sudo, so validate with visudo when available.
|
|
|
|
|
if ! command -v visudo >/dev/null || visudo -cf "$SCRIPTS/sudoers-t3-autopair" >/dev/null; then
|
|
|
|
|
install -m 0440 "$SCRIPTS/sudoers-t3-autopair" /etc/sudoers.d/t3-autopair
|
|
|
|
|
else
|
|
|
|
|
log "WARN: sudoers-t3-autopair failed visudo validation -> NOT installed"
|
|
|
|
|
fi
|
|
|
|
|
# 9d) unit files + enablement. Timers self-heal; t3-dispatch is long-running.
|
|
|
|
|
# t3-serve@ is a TEMPLATE (enabled per-user by the provisioner, not here).
|
|
|
|
|
for u in t3-serve@.service \
|
2026-06-20 20:10:40 +00:00
|
|
|
claude-auth-sync@.service claude-auth-sync@.timer \
|
workstation: setup-devvm.sh installs the systemd service layer (reproducible rebuild)
The t3 system units (t3-serve@, t3-autoupdate, t3-backup-state, t3-provision-users,
t3-dispatch) + the t3-dispatch Go binary + t3-mint + the sudoers grant were all
hand-scp'd and would NOT survive a fresh devvm. setup-devvm.sh now installs + enables
them: build-if-absent for the Go binary, visudo-validated sudoers (a malformed
/etc/sudoers.d file breaks all sudo), timers self-heal, t3-dispatch system account
created if absent. t3-serve@ stays a per-user template enabled by the provisioner;
the ttyd terminal-lobby chain ships from its own repo (viktor/terminal-lobby).
Verified: shellcheck clean, go build compiles, visudo parses the sudoers, units parse.
NOT run live (would re-assert apt/npm on the shared host) — exercised on next rebuild.
[ci skip]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:07:20 +00:00
|
|
|
t3-autoupdate.service t3-autoupdate.timer \
|
2026-06-21 12:36:13 +00:00
|
|
|
t3-migrate-idle.service t3-migrate-idle.timer \
|
workstation: setup-devvm.sh installs the systemd service layer (reproducible rebuild)
The t3 system units (t3-serve@, t3-autoupdate, t3-backup-state, t3-provision-users,
t3-dispatch) + the t3-dispatch Go binary + t3-mint + the sudoers grant were all
hand-scp'd and would NOT survive a fresh devvm. setup-devvm.sh now installs + enables
them: build-if-absent for the Go binary, visudo-validated sudoers (a malformed
/etc/sudoers.d file breaks all sudo), timers self-heal, t3-dispatch system account
created if absent. t3-serve@ stays a per-user template enabled by the provisioner;
the ttyd terminal-lobby chain ships from its own repo (viktor/terminal-lobby).
Verified: shellcheck clean, go build compiles, visudo parses the sudoers, units parse.
NOT run live (would re-assert apt/npm on the shared host) — exercised on next rebuild.
[ci skip]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:07:20 +00:00
|
|
|
t3-backup-state.service t3-backup-state.timer \
|
|
|
|
|
t3-provision-users.service t3-provision-users.timer \
|
|
|
|
|
t3-dispatch.service; do
|
|
|
|
|
install -m 0644 "$SCRIPTS/$u" "/etc/systemd/system/$u"
|
|
|
|
|
done
|
2026-06-20 20:10:40 +00:00
|
|
|
log "claude auth: per-user sync script + template units installed"
|
workstation: per-user playwright browser MCP for all users, reproducible from git
Viktor asked that the playwright browser MCP be available for every devvm user
in every directory, with each user running their own server and multiple
concurrent sessions per user.
Before this, playwright was hand-set-up per user (~/.config/systemd/user/
playwright-mcp.service on 8931/8932/8933) and only wizard was actually wired —
emo's and anca's servers ran but their ~/.claude.json had no playwright entry,
so their Claude never connected. None of it was reproducible from git (units,
refresh script, and the Vault snapshot token lived only in user homes), so a
devvm rebuild would silently lose it.
This makes it reproducible and fixes the unwired users:
- roster_engine.py: sticky per-user PLAYWRIGHT_PORT (PLAYWRIGHT_BASE_PORT=8931,
allocated for every roster user incl. the admin), emitted in the derive JSON.
- scripts/workstation/playwright/: system-level TEMPLATE units
(playwright-mcp@.service + playwright-snapshot-refresh@.{service,timer},
User=%i — system manager, so no systemd --user / linger) + the refresh script.
@playwright/mcp pinned to 0.0.76 (avoids the @latest silent-fleet-roll
footgun, same rationale as T3_PIN).
- setup-devvm.sh: install the templates + script (9e); stage the chrome-service
snapshot bearer token from Vault to a root file (8c) — the hourly root
reconcile has no Vault token, mirrors the Claude OAuth staging in 8a.
- t3-provision-users.sh: install_playwright() (ALL tiers incl. admin) writes
PLAYWRIGHT_PORT, seeds the token if-absent, wires the user-scope ~/.claude.json
by running `claude mcp add` AS the user (clobber-proof + if-absent, so it fixes
existing/new/admin without rewriting a populated config), and enable --now's the
instances (idempotent, never restarts a running server). Also hardened the
section-1 *.env scan to skip the new playwright-*.env files (no T3_PORT -> grep
no-match would abort under set -e -o pipefail).
- Docs: chrome-service-snapshot runbook (new Provisioning section + system-unit
commands), multi-tenancy.md, and the 2026-06-07 plan Task 2.3.
Supersedes the hand-made per-user --user units (one-time idle-gated migration to
follow on the live host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:33:47 +00:00
|
|
|
# 9e) per-user playwright-mcp browser MCP: system-level TEMPLATE units (one
|
|
|
|
|
# instance per OS user) + the snapshot-refresh script. Reproducible-from-git
|
|
|
|
|
# replacement for the hand-made ~/.config/systemd/user/playwright-* units
|
|
|
|
|
# (no systemd --user / linger needed). Enabled per-user by the provisioner;
|
|
|
|
|
# PLAYWRIGHT_PORT (roster_engine) + the chrome-service token (8c) feed them.
|
|
|
|
|
install -m 0755 "$HERE/playwright/playwright-snapshot-refresh" /usr/local/bin/playwright-snapshot-refresh
|
|
|
|
|
for u in playwright-mcp@.service playwright-snapshot-refresh@.service playwright-snapshot-refresh@.timer; do
|
|
|
|
|
install -m 0644 "$HERE/playwright/$u" "/etc/systemd/system/$u"
|
|
|
|
|
done
|
|
|
|
|
log "playwright: template units + snapshot-refresh script installed (per-user enable in provisioner)"
|
workstation: setup-devvm.sh installs the systemd service layer (reproducible rebuild)
The t3 system units (t3-serve@, t3-autoupdate, t3-backup-state, t3-provision-users,
t3-dispatch) + the t3-dispatch Go binary + t3-mint + the sudoers grant were all
hand-scp'd and would NOT survive a fresh devvm. setup-devvm.sh now installs + enables
them: build-if-absent for the Go binary, visudo-validated sudoers (a malformed
/etc/sudoers.d file breaks all sudo), timers self-heal, t3-dispatch system account
created if absent. t3-serve@ stays a per-user template enabled by the provisioner;
the ttyd terminal-lobby chain ships from its own repo (viktor/terminal-lobby).
Verified: shellcheck clean, go build compiles, visudo parses the sudoers, units parse.
NOT run live (would re-assert apt/npm on the shared host) — exercised on next rebuild.
[ci skip]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:07:20 +00:00
|
|
|
systemctl daemon-reload
|
|
|
|
|
systemctl enable --now t3-dispatch.service \
|
2026-06-21 12:36:13 +00:00
|
|
|
t3-autoupdate.timer t3-backup-state.timer t3-provision-users.timer t3-migrate-idle.timer >/dev/null 2>&1 || \
|
workstation: setup-devvm.sh installs the systemd service layer (reproducible rebuild)
The t3 system units (t3-serve@, t3-autoupdate, t3-backup-state, t3-provision-users,
t3-dispatch) + the t3-dispatch Go binary + t3-mint + the sudoers grant were all
hand-scp'd and would NOT survive a fresh devvm. setup-devvm.sh now installs + enables
them: build-if-absent for the Go binary, visudo-validated sudoers (a malformed
/etc/sudoers.d file breaks all sudo), timers self-heal, t3-dispatch system account
created if absent. t3-serve@ stays a per-user template enabled by the provisioner;
the ttyd terminal-lobby chain ships from its own repo (viktor/terminal-lobby).
Verified: shellcheck clean, go build compiles, visudo parses the sudoers, units parse.
NOT run live (would re-assert apt/npm on the shared host) — exercised on next rebuild.
[ci skip]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:07:20 +00:00
|
|
|
log "WARN: some units failed to enable (check: systemctl status t3-dispatch t3-*.timer)"
|
|
|
|
|
log "service units installed + enabled (t3-dispatch + 3 timers; t3-serve@ per-user)"
|
|
|
|
|
|
workstation: per-user memory caps + systemd-oomd backstop on devvm
The shared devvm keeps overloading and had to be hard-killed again today
(2026-06-22): a runaway in one user's ssh/tmux session (a 10G ugrep, plus
stacked max-effort agents) grew unbounded, spilled into the disk swap, and
swap-thrashed the throttled virtual disk into an IO storm until the box wedged.
Root cause: ssh/tmux work runs under user-<uid>.slice, left memory-uncontained
by the explicit 2026-06-10 "swap-only" decision, while only the t3-serve tree
was capped. So one user could starve everyone.
This bounds every user on BOTH trees (MemoryHigh=12G, MemoryMax=16G,
MemorySwapMax=0 so work OOMs locally at its ceiling instead of thrashing swap),
adds a systemd-oomd PSI backstop that sheds the single worst work cgroup under
box-wide pressure while leaving system.slice (sshd/services/your way in)
protected, gives system.slice a fair-share CPU/IO priority edge, and routes
docker containers into a capped, oomd-policed docker.slice so they can't dodge
the caps or mis-target oomd. All durable in setup-devvm.sh so a VM rebuild
reproduces them; systemd-oomd added to packages.txt.
Applied live and verified: oomctl shows the backstop armed (not dry-run) on the
work slices with system.slice protected; a capped-balloon stress test OOM-killed
locally at the ceiling with swap flat (no thrash).
Post-mortem: docs/post-mortems/2026-06-22-devvm-mem-io-overload-containment.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:25:09 +00:00
|
|
|
# 10) RESOURCE CONTAINMENT (2026-06-22): bound per-user memory + an OOM backstop so
|
|
|
|
|
# ONE user's runaway can never IO/memory-overload the shared box. History: the
|
|
|
|
|
# 2026-06-10 "swap-only, ssh/tmux memory-uncontained" decision let a single
|
|
|
|
|
# user's runaway (a 10G `ugrep`; agent storms) swap-thrash the 60/60-throttled
|
|
|
|
|
# virtual disk into an IO storm + multi-minute freeze (hard-killed 2026-06-22).
|
|
|
|
|
# t3-serve@ was already capped (its [Service] block); the HOLE was the uncapped
|
|
|
|
|
# user-<uid>.slice (all ssh/tmux work). Design — per user, on BOTH trees:
|
2026-06-22 10:39:16 +00:00
|
|
|
# MemoryHigh=12G soft (throttles a runaway to a crawl), MemoryMax=16G hard,
|
|
|
|
|
# MemorySwapMax=0 (work never touches disk swap → no thrash; it OOMs locally at
|
|
|
|
|
# the ceiling instead), plus fair-share CPU/IO weights.
|
|
|
|
|
# BACKSTOP = earlyoom, NOT systemd-oomd. We first shipped systemd-oomd but it is
|
|
|
|
|
# INERT with swap=0: its pressure-kill only acts on cgroups doing active reclaim
|
|
|
|
|
# (pgscan rising), and a no-swap anon workload never reclaims — verified live, a
|
|
|
|
|
# cgroup at 99% memory.pressure / pgscan=0 was never killed. earlyoom instead
|
|
|
|
|
# watches FREE RAM (MemAvailable%) and SIGTERMs the biggest process at 5% / -k 3%,
|
|
|
|
|
# swap-independent and reliable. It --avoids sshd/systemd/dockerd (your way in
|
|
|
|
|
# stays alive) and --prefers the agent/browser hogs. earlyoom pkg = packages.txt
|
|
|
|
|
# (§1). Per-cgroup MemoryMax is the PRIMARY guard; earlyoom is the aggregate net.
|
workstation: per-user memory caps + systemd-oomd backstop on devvm
The shared devvm keeps overloading and had to be hard-killed again today
(2026-06-22): a runaway in one user's ssh/tmux session (a 10G ugrep, plus
stacked max-effort agents) grew unbounded, spilled into the disk swap, and
swap-thrashed the throttled virtual disk into an IO storm until the box wedged.
Root cause: ssh/tmux work runs under user-<uid>.slice, left memory-uncontained
by the explicit 2026-06-10 "swap-only" decision, while only the t3-serve tree
was capped. So one user could starve everyone.
This bounds every user on BOTH trees (MemoryHigh=12G, MemoryMax=16G,
MemorySwapMax=0 so work OOMs locally at its ceiling instead of thrashing swap),
adds a systemd-oomd PSI backstop that sheds the single worst work cgroup under
box-wide pressure while leaving system.slice (sshd/services/your way in)
protected, gives system.slice a fair-share CPU/IO priority edge, and routes
docker containers into a capped, oomd-policed docker.slice so they can't dodge
the caps or mis-target oomd. All durable in setup-devvm.sh so a VM rebuild
reproduces them; systemd-oomd added to packages.txt.
Applied live and verified: oomctl shows the backstop armed (not dry-run) on the
work slices with system.slice protected; a capped-balloon stress test OOM-killed
locally at the ceiling with swap flat (no thrash).
Post-mortem: docs/post-mortems/2026-06-22-devvm-mem-io-overload-containment.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:25:09 +00:00
|
|
|
# Post-mortem: docs/post-mortems/2026-06-22-devvm-mem-io-overload-containment.md
|
|
|
|
|
|
2026-06-22 10:39:16 +00:00
|
|
|
# 10a) per-user caps + fair-share weights on EVERY user-<uid>.slice (ssh/tmux)
|
workstation: per-user memory caps + systemd-oomd backstop on devvm
The shared devvm keeps overloading and had to be hard-killed again today
(2026-06-22): a runaway in one user's ssh/tmux session (a 10G ugrep, plus
stacked max-effort agents) grew unbounded, spilled into the disk swap, and
swap-thrashed the throttled virtual disk into an IO storm until the box wedged.
Root cause: ssh/tmux work runs under user-<uid>.slice, left memory-uncontained
by the explicit 2026-06-10 "swap-only" decision, while only the t3-serve tree
was capped. So one user could starve everyone.
This bounds every user on BOTH trees (MemoryHigh=12G, MemoryMax=16G,
MemorySwapMax=0 so work OOMs locally at its ceiling instead of thrashing swap),
adds a systemd-oomd PSI backstop that sheds the single worst work cgroup under
box-wide pressure while leaving system.slice (sshd/services/your way in)
protected, gives system.slice a fair-share CPU/IO priority edge, and routes
docker containers into a capped, oomd-policed docker.slice so they can't dodge
the caps or mis-target oomd. All durable in setup-devvm.sh so a VM rebuild
reproduces them; systemd-oomd added to packages.txt.
Applied live and verified: oomctl shows the backstop armed (not dry-run) on the
work slices with system.slice protected; a capped-balloon stress test OOM-killed
locally at the ceiling with swap flat (no thrash).
Post-mortem: docs/post-mortems/2026-06-22-devvm-mem-io-overload-containment.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:25:09 +00:00
|
|
|
install -d -m 0755 /etc/systemd/system/user-.slice.d
|
|
|
|
|
cat > /etc/systemd/system/user-.slice.d/50-devvm-resource.conf <<'SLICE_EOF'
|
|
|
|
|
# Per-user containment for the shared devvm (setup-devvm.sh §10, 2026-06-22).
|
|
|
|
|
# Applies to EACH user-<uid>.slice = all of one user's ssh/tmux work. Mirrors the
|
|
|
|
|
# t3-serve@.service caps so a user is bounded in whichever surface they work in.
|
|
|
|
|
[Slice]
|
|
|
|
|
MemoryAccounting=yes
|
|
|
|
|
MemoryHigh=12G
|
|
|
|
|
MemoryMax=16G
|
|
|
|
|
MemorySwapMax=0
|
|
|
|
|
CPUAccounting=yes
|
|
|
|
|
CPUWeight=100
|
|
|
|
|
IOAccounting=yes
|
|
|
|
|
IOWeight=100
|
|
|
|
|
SLICE_EOF
|
|
|
|
|
|
2026-06-22 10:39:16 +00:00
|
|
|
# 10b) earlyoom backstop config — RAM-threshold, swap-INDEPENDENT (see header note
|
|
|
|
|
# on why systemd-oomd is inert with swap=0). The Debian unit reads /etc/default.
|
|
|
|
|
cat > /etc/default/earlyoom <<'EARLYOOM_EOF'
|
|
|
|
|
# devvm aggregate OOM backstop (setup-devvm.sh §10, 2026-06-22). Watches FREE RAM
|
|
|
|
|
# (MemAvailable%) and kills the biggest task before the box exhausts. Unlike
|
|
|
|
|
# systemd-oomd it needs NO swap/reclaim, so it works with our swap=0 work cgroups.
|
|
|
|
|
# -m 5,3 SIGTERM the victim at MemAvailable<5%, SIGKILL at <3%
|
|
|
|
|
# -s 100,100 ignore swap in the decision (RAM-only; work cgroups are swap=0)
|
|
|
|
|
# --avoid never the box's nervous system / your way back in
|
|
|
|
|
# --prefer target the agent/browser/build hogs that actually exhaust RAM
|
|
|
|
|
# -r 3600 hourly memory report (the 60s default is log spam)
|
|
|
|
|
EARLYOOM_ARGS="-m 5,3 -s 100,100 -r 3600 --avoid ^(systemd|systemd-.*|sshd|dockerd|containerd|init|t3-dispatch|tmux.*)$ --prefer ^(python3|node|chrome|chromium|ugrep|rg|go|claude)$"
|
|
|
|
|
EARLYOOM_EOF
|
workstation: per-user memory caps + systemd-oomd backstop on devvm
The shared devvm keeps overloading and had to be hard-killed again today
(2026-06-22): a runaway in one user's ssh/tmux session (a 10G ugrep, plus
stacked max-effort agents) grew unbounded, spilled into the disk swap, and
swap-thrashed the throttled virtual disk into an IO storm until the box wedged.
Root cause: ssh/tmux work runs under user-<uid>.slice, left memory-uncontained
by the explicit 2026-06-10 "swap-only" decision, while only the t3-serve tree
was capped. So one user could starve everyone.
This bounds every user on BOTH trees (MemoryHigh=12G, MemoryMax=16G,
MemorySwapMax=0 so work OOMs locally at its ceiling instead of thrashing swap),
adds a systemd-oomd PSI backstop that sheds the single worst work cgroup under
box-wide pressure while leaving system.slice (sshd/services/your way in)
protected, gives system.slice a fair-share CPU/IO priority edge, and routes
docker containers into a capped, oomd-policed docker.slice so they can't dodge
the caps or mis-target oomd. All durable in setup-devvm.sh so a VM rebuild
reproduces them; systemd-oomd added to packages.txt.
Applied live and verified: oomctl shows the backstop armed (not dry-run) on the
work slices with system.slice protected; a capped-balloon stress test OOM-killed
locally at the ceiling with swap flat (no thrash).
Post-mortem: docs/post-mortems/2026-06-22-devvm-mem-io-overload-containment.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:25:09 +00:00
|
|
|
|
2026-06-22 10:39:16 +00:00
|
|
|
# 10c) capped docker.slice (top-level sibling of system/user slices); daemon.json
|
|
|
|
|
# cgroup-parent (10d) makes EVERY container land here under one bounded budget.
|
workstation: per-user memory caps + systemd-oomd backstop on devvm
The shared devvm keeps overloading and had to be hard-killed again today
(2026-06-22): a runaway in one user's ssh/tmux session (a 10G ugrep, plus
stacked max-effort agents) grew unbounded, spilled into the disk swap, and
swap-thrashed the throttled virtual disk into an IO storm until the box wedged.
Root cause: ssh/tmux work runs under user-<uid>.slice, left memory-uncontained
by the explicit 2026-06-10 "swap-only" decision, while only the t3-serve tree
was capped. So one user could starve everyone.
This bounds every user on BOTH trees (MemoryHigh=12G, MemoryMax=16G,
MemorySwapMax=0 so work OOMs locally at its ceiling instead of thrashing swap),
adds a systemd-oomd PSI backstop that sheds the single worst work cgroup under
box-wide pressure while leaving system.slice (sshd/services/your way in)
protected, gives system.slice a fair-share CPU/IO priority edge, and routes
docker containers into a capped, oomd-policed docker.slice so they can't dodge
the caps or mis-target oomd. All durable in setup-devvm.sh so a VM rebuild
reproduces them; systemd-oomd added to packages.txt.
Applied live and verified: oomctl shows the backstop armed (not dry-run) on the
work slices with system.slice protected; a capped-balloon stress test OOM-killed
locally at the ceiling with swap flat (no thrash).
Post-mortem: docs/post-mortems/2026-06-22-devvm-mem-io-overload-containment.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:25:09 +00:00
|
|
|
cat > /etc/systemd/system/docker.slice <<'DOCKER_SLICE_EOF'
|
|
|
|
|
# All docker containers live here (cgroup-parent in /etc/docker/daemon.json) so
|
2026-06-22 10:39:16 +00:00
|
|
|
# they share one bounded budget and a runaway container is capped at MemoryMax
|
|
|
|
|
# (cgroup-OOM'd locally) instead of escaping into the uncapped system.slice.
|
|
|
|
|
# setup-devvm.sh §10, 2026-06-22.
|
workstation: per-user memory caps + systemd-oomd backstop on devvm
The shared devvm keeps overloading and had to be hard-killed again today
(2026-06-22): a runaway in one user's ssh/tmux session (a 10G ugrep, plus
stacked max-effort agents) grew unbounded, spilled into the disk swap, and
swap-thrashed the throttled virtual disk into an IO storm until the box wedged.
Root cause: ssh/tmux work runs under user-<uid>.slice, left memory-uncontained
by the explicit 2026-06-10 "swap-only" decision, while only the t3-serve tree
was capped. So one user could starve everyone.
This bounds every user on BOTH trees (MemoryHigh=12G, MemoryMax=16G,
MemorySwapMax=0 so work OOMs locally at its ceiling instead of thrashing swap),
adds a systemd-oomd PSI backstop that sheds the single worst work cgroup under
box-wide pressure while leaving system.slice (sshd/services/your way in)
protected, gives system.slice a fair-share CPU/IO priority edge, and routes
docker containers into a capped, oomd-policed docker.slice so they can't dodge
the caps or mis-target oomd. All durable in setup-devvm.sh so a VM rebuild
reproduces them; systemd-oomd added to packages.txt.
Applied live and verified: oomctl shows the backstop armed (not dry-run) on the
work slices with system.slice protected; a capped-balloon stress test OOM-killed
locally at the ceiling with swap flat (no thrash).
Post-mortem: docs/post-mortems/2026-06-22-devvm-mem-io-overload-containment.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:25:09 +00:00
|
|
|
[Unit]
|
2026-06-22 10:39:16 +00:00
|
|
|
Description=Docker containers slice (capped)
|
workstation: per-user memory caps + systemd-oomd backstop on devvm
The shared devvm keeps overloading and had to be hard-killed again today
(2026-06-22): a runaway in one user's ssh/tmux session (a 10G ugrep, plus
stacked max-effort agents) grew unbounded, spilled into the disk swap, and
swap-thrashed the throttled virtual disk into an IO storm until the box wedged.
Root cause: ssh/tmux work runs under user-<uid>.slice, left memory-uncontained
by the explicit 2026-06-10 "swap-only" decision, while only the t3-serve tree
was capped. So one user could starve everyone.
This bounds every user on BOTH trees (MemoryHigh=12G, MemoryMax=16G,
MemorySwapMax=0 so work OOMs locally at its ceiling instead of thrashing swap),
adds a systemd-oomd PSI backstop that sheds the single worst work cgroup under
box-wide pressure while leaving system.slice (sshd/services/your way in)
protected, gives system.slice a fair-share CPU/IO priority edge, and routes
docker containers into a capped, oomd-policed docker.slice so they can't dodge
the caps or mis-target oomd. All durable in setup-devvm.sh so a VM rebuild
reproduces them; systemd-oomd added to packages.txt.
Applied live and verified: oomctl shows the backstop armed (not dry-run) on the
work slices with system.slice protected; a capped-balloon stress test OOM-killed
locally at the ceiling with swap flat (no thrash).
Post-mortem: docs/post-mortems/2026-06-22-devvm-mem-io-overload-containment.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:25:09 +00:00
|
|
|
[Slice]
|
|
|
|
|
MemoryAccounting=yes
|
|
|
|
|
MemoryHigh=6G
|
|
|
|
|
MemoryMax=8G
|
|
|
|
|
MemorySwapMax=0
|
|
|
|
|
CPUAccounting=yes
|
|
|
|
|
CPUWeight=100
|
|
|
|
|
IOAccounting=yes
|
|
|
|
|
IOWeight=100
|
|
|
|
|
DOCKER_SLICE_EOF
|
|
|
|
|
|
|
|
|
|
# 10d) point dockerd at docker.slice (idempotent JSON merge; flag a needed restart).
|
|
|
|
|
# python preserves the rest of daemon.json (buildkit, nvidia runtime, etc.).
|
|
|
|
|
docker_restart=0
|
|
|
|
|
# if-condition form so the deliberate non-zero exit (10=changed) does NOT trip the
|
|
|
|
|
# script's `set -e`; $? in the else branch is the python exit code.
|
|
|
|
|
if python3 - <<'PY'
|
|
|
|
|
import json, os, sys
|
|
|
|
|
p = "/etc/docker/daemon.json"
|
|
|
|
|
try:
|
|
|
|
|
d = json.load(open(p)) if os.path.exists(p) else {}
|
|
|
|
|
except Exception:
|
|
|
|
|
sys.exit(2) # malformed -> don't touch
|
|
|
|
|
if d.get("cgroup-parent") == "docker.slice":
|
|
|
|
|
sys.exit(0) # already correct -> no restart
|
|
|
|
|
d["cgroup-parent"] = "docker.slice"
|
|
|
|
|
json.dump(d, open(p, "w"), indent=4)
|
|
|
|
|
sys.exit(10) # changed -> restart needed
|
|
|
|
|
PY
|
|
|
|
|
then rc=0; else rc=$?; fi
|
|
|
|
|
case $rc in
|
|
|
|
|
0) : ;;
|
|
|
|
|
10) docker_restart=1 ;;
|
|
|
|
|
*) log "WARN: could not patch /etc/docker/daemon.json — docker.slice NOT wired" ;;
|
|
|
|
|
esac
|
|
|
|
|
|
2026-06-22 10:39:16 +00:00
|
|
|
# 10e) t3-serve@ instances need no extra drop-in: their per-instance MemoryMax /
|
|
|
|
|
# MemorySwapMax caps live in t3-serve@.service [Service]; earlyoom (10b) is the
|
|
|
|
|
# box-wide net. (The earlier oomd slice-policing drop-in was removed — inert.)
|
workstation: per-user memory caps + systemd-oomd backstop on devvm
The shared devvm keeps overloading and had to be hard-killed again today
(2026-06-22): a runaway in one user's ssh/tmux session (a 10G ugrep, plus
stacked max-effort agents) grew unbounded, spilled into the disk swap, and
swap-thrashed the throttled virtual disk into an IO storm until the box wedged.
Root cause: ssh/tmux work runs under user-<uid>.slice, left memory-uncontained
by the explicit 2026-06-10 "swap-only" decision, while only the t3-serve tree
was capped. So one user could starve everyone.
This bounds every user on BOTH trees (MemoryHigh=12G, MemoryMax=16G,
MemorySwapMax=0 so work OOMs locally at its ceiling instead of thrashing swap),
adds a systemd-oomd PSI backstop that sheds the single worst work cgroup under
box-wide pressure while leaving system.slice (sshd/services/your way in)
protected, gives system.slice a fair-share CPU/IO priority edge, and routes
docker containers into a capped, oomd-policed docker.slice so they can't dodge
the caps or mis-target oomd. All durable in setup-devvm.sh so a VM rebuild
reproduces them; systemd-oomd added to packages.txt.
Applied live and verified: oomctl shows the backstop armed (not dry-run) on the
work slices with system.slice protected; a capped-balloon stress test OOM-killed
locally at the ceiling with swap flat (no thrash).
Post-mortem: docs/post-mortems/2026-06-22-devvm-mem-io-overload-containment.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:25:09 +00:00
|
|
|
|
|
|
|
|
# 10f) give system.slice a priority edge so sshd/services stay snappy under
|
|
|
|
|
# contention (weights are work-conserving — users still get idle CPU/IO).
|
|
|
|
|
install -d -m 0755 /etc/systemd/system/system.slice.d
|
|
|
|
|
cat > /etc/systemd/system/system.slice.d/50-devvm-priority.conf <<'SYS_EOF'
|
|
|
|
|
# Keep the box's nervous system responsive under contention (setup-devvm.sh §10).
|
|
|
|
|
[Slice]
|
|
|
|
|
CPUAccounting=yes
|
|
|
|
|
CPUWeight=200
|
|
|
|
|
IOAccounting=yes
|
|
|
|
|
IOWeight=200
|
|
|
|
|
SYS_EOF
|
|
|
|
|
|
2026-06-22 10:39:16 +00:00
|
|
|
# 10g) activate: reload, arm earlyoom, restart dockerd ONLY if daemon.json changed.
|
workstation: per-user memory caps + systemd-oomd backstop on devvm
The shared devvm keeps overloading and had to be hard-killed again today
(2026-06-22): a runaway in one user's ssh/tmux session (a 10G ugrep, plus
stacked max-effort agents) grew unbounded, spilled into the disk swap, and
swap-thrashed the throttled virtual disk into an IO storm until the box wedged.
Root cause: ssh/tmux work runs under user-<uid>.slice, left memory-uncontained
by the explicit 2026-06-10 "swap-only" decision, while only the t3-serve tree
was capped. So one user could starve everyone.
This bounds every user on BOTH trees (MemoryHigh=12G, MemoryMax=16G,
MemorySwapMax=0 so work OOMs locally at its ceiling instead of thrashing swap),
adds a systemd-oomd PSI backstop that sheds the single worst work cgroup under
box-wide pressure while leaving system.slice (sshd/services/your way in)
protected, gives system.slice a fair-share CPU/IO priority edge, and routes
docker containers into a capped, oomd-policed docker.slice so they can't dodge
the caps or mis-target oomd. All durable in setup-devvm.sh so a VM rebuild
reproduces them; systemd-oomd added to packages.txt.
Applied live and verified: oomctl shows the backstop armed (not dry-run) on the
work slices with system.slice protected; a capped-balloon stress test OOM-killed
locally at the ceiling with swap flat (no thrash).
Post-mortem: docs/post-mortems/2026-06-22-devvm-mem-io-overload-containment.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:25:09 +00:00
|
|
|
systemctl daemon-reload
|
2026-06-22 10:39:16 +00:00
|
|
|
# earlyoom reads /etc/default/earlyoom (10b); enable + restart so new args take effect
|
|
|
|
|
# even on a re-run where it was already running.
|
|
|
|
|
systemctl enable --now earlyoom.service >/dev/null 2>&1 \
|
|
|
|
|
|| log "WARN: earlyoom failed to enable — is the package installed? (packages.txt §1)"
|
|
|
|
|
systemctl restart earlyoom.service 2>/dev/null || true
|
|
|
|
|
# systemd-oomd is inert with swap=0 (see header) — ensure it isn't also running from
|
|
|
|
|
# an earlier iteration of this section. No-op if the package was never installed.
|
|
|
|
|
systemctl disable --now systemd-oomd.service >/dev/null 2>&1 || true
|
workstation: per-user memory caps + systemd-oomd backstop on devvm
The shared devvm keeps overloading and had to be hard-killed again today
(2026-06-22): a runaway in one user's ssh/tmux session (a 10G ugrep, plus
stacked max-effort agents) grew unbounded, spilled into the disk swap, and
swap-thrashed the throttled virtual disk into an IO storm until the box wedged.
Root cause: ssh/tmux work runs under user-<uid>.slice, left memory-uncontained
by the explicit 2026-06-10 "swap-only" decision, while only the t3-serve tree
was capped. So one user could starve everyone.
This bounds every user on BOTH trees (MemoryHigh=12G, MemoryMax=16G,
MemorySwapMax=0 so work OOMs locally at its ceiling instead of thrashing swap),
adds a systemd-oomd PSI backstop that sheds the single worst work cgroup under
box-wide pressure while leaving system.slice (sshd/services/your way in)
protected, gives system.slice a fair-share CPU/IO priority edge, and routes
docker containers into a capped, oomd-policed docker.slice so they can't dodge
the caps or mis-target oomd. All durable in setup-devvm.sh so a VM rebuild
reproduces them; systemd-oomd added to packages.txt.
Applied live and verified: oomctl shows the backstop armed (not dry-run) on the
work slices with system.slice protected; a capped-balloon stress test OOM-killed
locally at the ceiling with swap flat (no thrash).
Post-mortem: docs/post-mortems/2026-06-22-devvm-mem-io-overload-containment.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:25:09 +00:00
|
|
|
if [[ $docker_restart -eq 1 ]] && systemctl is-active --quiet docker; then
|
|
|
|
|
log "restarting dockerd to apply cgroup-parent=docker.slice (running containers bounce briefly)"
|
|
|
|
|
systemctl restart docker || log "WARN: docker restart failed"
|
|
|
|
|
fi
|
2026-06-22 10:39:16 +00:00
|
|
|
log "§10 resource containment: per-user 12G/16G swap=0, earlyoom RAM backstop, docker.slice"
|
workstation: per-user memory caps + systemd-oomd backstop on devvm
The shared devvm keeps overloading and had to be hard-killed again today
(2026-06-22): a runaway in one user's ssh/tmux session (a 10G ugrep, plus
stacked max-effort agents) grew unbounded, spilled into the disk swap, and
swap-thrashed the throttled virtual disk into an IO storm until the box wedged.
Root cause: ssh/tmux work runs under user-<uid>.slice, left memory-uncontained
by the explicit 2026-06-10 "swap-only" decision, while only the t3-serve tree
was capped. So one user could starve everyone.
This bounds every user on BOTH trees (MemoryHigh=12G, MemoryMax=16G,
MemorySwapMax=0 so work OOMs locally at its ceiling instead of thrashing swap),
adds a systemd-oomd PSI backstop that sheds the single worst work cgroup under
box-wide pressure while leaving system.slice (sshd/services/your way in)
protected, gives system.slice a fair-share CPU/IO priority edge, and routes
docker containers into a capped, oomd-policed docker.slice so they can't dodge
the caps or mis-target oomd. All durable in setup-devvm.sh so a VM rebuild
reproduces them; systemd-oomd added to packages.txt.
Applied live and verified: oomctl shows the backstop armed (not dry-run) on the
work slices with system.slice protected; a capped-balloon stress test OOM-killed
locally at the ceiling with swap flat (no thrash).
Post-mortem: docs/post-mortems/2026-06-22-devvm-mem-io-overload-containment.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:25:09 +00:00
|
|
|
|
2026-06-20 20:10:40 +00:00
|
|
|
# Run one foreground reconcile while the admin Vault token borrowed in section 8
|
|
|
|
|
# is still available. This is what mints new roster users' isolated periodic
|
|
|
|
|
# Vault tokens; the hourly no-admin-token reconcile only maintains existing ones.
|
|
|
|
|
if [[ -n "${VAULT_TOKEN:-}" ]]; then
|
|
|
|
|
/usr/local/bin/t3-provision-users || log "WARN: foreground provisioner failed; scoped Claude-auth tokens may need a retry"
|
|
|
|
|
fi
|
|
|
|
|
|
2026-06-09 08:45:33 +00:00
|
|
|
log "OK (idempotent)"
|