stem95su: scheduled Drive->site sync CronJob (every 10m)
CronJob stem95su-gdrive-sync (*/10) mounts the content PVC RW and rclone-syncs the read-only Drive folder "claude" (stem claude/files) onto it (rclone/rclone:1.74.3, scope=drive.readonly, empty-source guard + --max-delete 25). ESO ExternalSecret stem95su-rclone <- Vault secret/stem95su. Requires the GCP OAuth app published to Production or the refresh token expires ~weekly. Lands the gdrive-sync stack on master (it had landed on a feature branch by accident on the shared devvm checkout). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
05b50d2b96
commit
6d224861c4
1168 changed files with 120 additions and 358547 deletions
3
scripts/workstation/.gitignore
vendored
3
scripts/workstation/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"claudeMd": "# Viktor Barzin homelab — shared multi-user Claude Code Workstation (devvm)\n\nYou are running as a specific OS user on a SHARED devvm Workstation, not as the admin. These org-wide rules apply to EVERY user and sit at the top of settings precedence (they cannot be overridden by a user's own config):\n\n- Respect your permission tier. Your kubectl, Vault, and infra access are scoped to your RBAC tier (admin / power-user / namespace-owner). Do not attempt to escalate privileges or reach another user's resources.\n- Secrets are per-user. Never read another user's home directory, credentials, tokens, or ~/.claude secrets. Your own secrets live in your home at mode 600.\n- Infrastructure changes go through Terraform/Terragrunt (scripts/tg apply) — never direct kubectl apply/edit/patch. Pushing to git does NOT deploy; applies are manual and admin-gated, so your edits cannot take effect without an admin apply.\n- Follow the engineering rules in ~/.claude/rules/ (execution, planning, quality) and every CLAUDE.md in the repo tree.\n- The monorepo is at ~/code. Non-admins get a git-crypt-LOCKED clone: secret files read as ciphertext — that is expected, not an error."
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# Declarative host toolset for the devvm Workstation (apt packages, one per line).
|
||||
# Consumed by setup-devvm.sh: apt-get install -y $(grep -vE '^\s*(#|$)' packages.txt)
|
||||
# Comments (#) and blank lines are ignored. Tools NOT in the standard apt repos
|
||||
# are listed below as comments with their real install path (handled explicitly
|
||||
# in setup-devvm.sh) so this manifest stays a safe argument to `apt-get install`.
|
||||
git
|
||||
zsh
|
||||
tmux
|
||||
ripgrep
|
||||
fd-find
|
||||
jq
|
||||
curl
|
||||
ca-certificates
|
||||
python3
|
||||
python3-yaml
|
||||
python3-pip
|
||||
podman
|
||||
|
||||
# --- installed by setup-devvm.sh via NON-apt paths (not apt-installable) ---
|
||||
# nodejs + npm -> NodeSource repo (claude-code needs node >= 18; distro nodejs is too old)
|
||||
# @anthropic-ai/claude-code -> npm install -g
|
||||
# kubectl -> k8s apt repo OR pinned binary (already present on devvm)
|
||||
# vault -> HashiCorp apt repo OR pinned binary (already present on devvm)
|
||||
# kubelogin (kubectl oidc-login) -> `kubectl krew install oidc-login` or int128/kubelogin release.
|
||||
# NOTE: the apt package literally named "kubelogin" is the AZURE
|
||||
# tool, NOT the OIDC one we need -- do not apt-install it.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
# THE single source of truth for the devvm Workstation lifecycle (onboard -> offboard).
|
||||
# Consumed by roster_engine.py (derive/validate) + t3-provision-users.sh (apply).
|
||||
#
|
||||
# os_user (the map KEY, pinned) -> authentik_user . k8s_user . tier . namespaces
|
||||
# The three identifiers differ per person (verified 2026-06-08) -- no email->username
|
||||
# derivation; record each explicitly.
|
||||
#
|
||||
# Tiers: admin | power-user | namespace-owner
|
||||
# admin - cluster-admin, unlocked tree, secrets (groups: sudo,docker,code-shared)
|
||||
# power-user - cluster-wide READ (no Secrets) via oidc-power-user-readonly; locked clone
|
||||
# namespace-owner - admin in their own namespace(s) only; locked clone
|
||||
#
|
||||
# wizard IS listed (as admin): the reconcile REGENERATES /etc/ttyd-user-map +
|
||||
# dispatch.json from this file, so omitting him would drop his t3 instance. The
|
||||
# provisioner skips account/group/clone mutations for already-existing users, so
|
||||
# listing him is safe (he keeps his unlocked tree + cluster-admin untouched).
|
||||
users:
|
||||
wizard: {authentik_user: vbarzin, k8s_user: wizard, tier: admin} # base config author + cluster-admin
|
||||
emo: {authentik_user: emil.barzin, k8s_user: emo, tier: power-user} # NET-NEW k8s_users entry (add as power-user before provisioning)
|
||||
ancamilea: {authentik_user: ancaelena98, k8s_user: anca, tier: namespace-owner, namespaces: [plotting-book]} # ALREADY provisioned in-cluster -- assert, don't re-create
|
||||
# gheorghe: {authentik_user: vabbit81, k8s_user: vabbit81, tier: namespace-owner, namespaces: [vabbit81]} # already a cluster ns-owner; uncomment to give him a devvm workstation
|
||||
|
|
@ -1,299 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Pure derivation + offboarding-diff engine for the devvm Workstation roster.
|
||||
|
||||
Functional core (this module, unit-tested) / imperative shell (the bash
|
||||
provisioner that consumes the JSON this emits and performs the host mutations).
|
||||
No host I/O lives in the tested functions. See PRD ViktorBarzin/infra#9.
|
||||
|
||||
The roster (`roster.yaml`) is the single source of truth for the workstation
|
||||
lifecycle. `os_user` is the pinned key; `authentik_user` / `k8s_user` differ
|
||||
per person and are recorded explicitly (no email->username derivation).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable
|
||||
|
||||
import yaml
|
||||
|
||||
BASE_PORT = 3773
|
||||
VALID_TIERS = ("admin", "power-user", "namespace-owner")
|
||||
# Tier -> supplementary groups the reconcile ENSURES (additive-only; never stripped).
|
||||
TIER_GROUPS: dict[str, tuple[str, ...]] = {
|
||||
"admin": ("code-shared", "docker", "sudo"),
|
||||
"power-user": (),
|
||||
"namespace-owner": (),
|
||||
}
|
||||
DEFAULT_SHELL = "/bin/zsh"
|
||||
_REVERSIBLE_OFFBOARD_KINDS = (
|
||||
"disable_instance",
|
||||
"unmap_dispatch",
|
||||
"remove_from_t3_group",
|
||||
"lock_login",
|
||||
"revoke_cluster_rbac",
|
||||
)
|
||||
|
||||
|
||||
class RosterError(ValueError):
|
||||
"""Raised when the roster is structurally invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class User:
|
||||
os_user: str
|
||||
authentik_user: str
|
||||
k8s_user: str
|
||||
tier: str
|
||||
namespaces: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Roster:
|
||||
users: dict[str, User] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Account:
|
||||
os_user: str
|
||||
tier: str
|
||||
shell: str
|
||||
login_locked: bool
|
||||
groups: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DesiredState:
|
||||
accounts: dict[str, Account]
|
||||
ttyd_user_map: str
|
||||
dispatch: dict[str, dict]
|
||||
ports: dict[str, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OffboardAction:
|
||||
os_user: str
|
||||
kind: str
|
||||
reversible: bool
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parsing + structural validation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_user(os_user: str, spec: dict) -> User:
|
||||
for required in ("authentik_user", "k8s_user", "tier"):
|
||||
if required not in spec:
|
||||
raise RosterError(f"user {os_user!r}: missing required field {required!r}")
|
||||
tier = spec["tier"]
|
||||
if tier not in VALID_TIERS:
|
||||
raise RosterError(
|
||||
f"user {os_user!r}: unknown tier {tier!r} (valid: {list(VALID_TIERS)})"
|
||||
)
|
||||
namespaces = tuple(spec.get("namespaces") or ())
|
||||
if tier == "namespace-owner" and not namespaces:
|
||||
raise RosterError(f"user {os_user!r}: namespace-owner requires namespaces")
|
||||
if tier != "namespace-owner" and namespaces:
|
||||
raise RosterError(f"user {os_user!r}: only namespace-owner may set namespaces")
|
||||
return User(os_user, spec["authentik_user"], spec["k8s_user"], tier, namespaces)
|
||||
|
||||
|
||||
def load_roster(text: str) -> Roster:
|
||||
data = yaml.safe_load(text) or {}
|
||||
users_raw = data.get("users") or {}
|
||||
return Roster({name: _parse_user(name, spec) for name, spec in users_raw.items()})
|
||||
|
||||
|
||||
def load_roster_file(path: str) -> Roster:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return load_roster(fh.read())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tier validation against live k8s_users (fail-loud)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidationIssue:
|
||||
os_user: str
|
||||
severity: str # "error" = tier conflict (abort) | "warn" = absent (grant pending)
|
||||
message: str
|
||||
|
||||
|
||||
def validate_tiers(
|
||||
roster: Roster, k8s_user_tiers: dict[str, str]
|
||||
) -> list[ValidationIssue]:
|
||||
"""Compare each roster user's tier against the live `k8s_users` map. A real
|
||||
conflict (roster tier != cluster tier) is an "error" (abort). A net-new user
|
||||
not yet in `k8s_users` is a "warn" (onboarding proceeds; the kubectl grant is
|
||||
pending). Admins are exempt (cluster-admin is granted out of band). An empty
|
||||
list means the roster is consistent with the cluster."""
|
||||
issues = []
|
||||
for user in roster.users.values():
|
||||
if user.tier == "admin":
|
||||
continue
|
||||
actual = k8s_user_tiers.get(user.k8s_user)
|
||||
if actual is None:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
user.os_user,
|
||||
"warn",
|
||||
f"{user.os_user}: tier {user.tier} but k8s_user {user.k8s_user!r} "
|
||||
f"absent from k8s_users (kubectl grant pending — add the entry)",
|
||||
)
|
||||
)
|
||||
elif actual != user.tier:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
user.os_user,
|
||||
"error",
|
||||
f"{user.os_user}: roster tier {user.tier} != k8s_users tier "
|
||||
f"{actual} for {user.k8s_user!r}",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def has_blocking_errors(issues: list[ValidationIssue]) -> bool:
|
||||
return any(issue.severity == "error" for issue in issues)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Desired-state derivation (sticky ports, ttyd map, dispatch, accounts)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _allocate_ports(roster: Roster, existing_ports: dict[str, int]) -> dict[str, int]:
|
||||
ports = {u: existing_ports[u] for u in roster.users if u in existing_ports}
|
||||
used = set(ports.values())
|
||||
for os_user in sorted(roster.users):
|
||||
if os_user in ports:
|
||||
continue
|
||||
candidate = BASE_PORT
|
||||
while candidate in used:
|
||||
candidate += 1
|
||||
ports[os_user] = candidate
|
||||
used.add(candidate)
|
||||
return ports
|
||||
|
||||
|
||||
_TTYD_MAP_HEADER = (
|
||||
"# Generated from roster.yaml by roster_engine.py — DO NOT EDIT BY HAND.\n"
|
||||
"# <authentik_user>=<os_user>; consumed by t3-dispatch.\n"
|
||||
)
|
||||
|
||||
|
||||
def derive_desired_state(
|
||||
roster: Roster, existing_ports: dict[str, int]
|
||||
) -> DesiredState:
|
||||
ports = _allocate_ports(roster, existing_ports)
|
||||
ordered = sorted(roster.users.values(), key=lambda u: ports[u.os_user])
|
||||
ttyd_lines = [f"{u.authentik_user}={u.os_user}" for u in ordered]
|
||||
ttyd_user_map = _TTYD_MAP_HEADER + "\n".join(ttyd_lines) + "\n"
|
||||
dispatch = {
|
||||
u.authentik_user: {"os_user": u.os_user, "port": ports[u.os_user]}
|
||||
for u in ordered
|
||||
}
|
||||
accounts = {
|
||||
u.os_user: Account(
|
||||
os_user=u.os_user,
|
||||
tier=u.tier,
|
||||
shell=DEFAULT_SHELL,
|
||||
login_locked=True,
|
||||
groups=TIER_GROUPS[u.tier],
|
||||
)
|
||||
for u in roster.users.values()
|
||||
}
|
||||
return DesiredState(accounts, ttyd_user_map, dispatch, ports)
|
||||
|
||||
|
||||
def groups_to_add(desired: Iterable[str], current: Iterable[str]) -> list[str]:
|
||||
"""Additive-only: the groups to `gpasswd -a`. Never proposes a removal, so a
|
||||
routine reconcile can't strip a pre-existing user's legacy groups."""
|
||||
return sorted(set(desired) - set(current))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Offboarding diff (staged: reversible cut, then gated destructive removal)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def to_deprovision(old: Roster, new: Roster) -> list[str]:
|
||||
return sorted(set(old.users) - set(new.users))
|
||||
|
||||
|
||||
def offboard_plan(
|
||||
old: Roster, new: Roster, *, include_destructive: bool
|
||||
) -> list[OffboardAction]:
|
||||
"""Staged offboarding actions for users dropped from the roster. The
|
||||
reversible cut (disable instance, unmap, lock, revoke RBAC) is always
|
||||
returned; the irreversible `userdel_archive` is included ONLY when
|
||||
explicitly requested, so it can never be auto-applied by a reconcile."""
|
||||
plan: list[OffboardAction] = []
|
||||
for os_user in to_deprovision(old, new):
|
||||
plan.extend(
|
||||
OffboardAction(os_user, kind, True) for kind in _REVERSIBLE_OFFBOARD_KINDS
|
||||
)
|
||||
if include_destructive:
|
||||
plan.append(OffboardAction(os_user, "userdel_archive", False))
|
||||
return plan
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI adapter (imperative shell entrypoint — consumed by t3-provision-users.sh)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _desired_state_to_dict(ds: DesiredState) -> dict:
|
||||
return {
|
||||
"accounts": {
|
||||
name: {
|
||||
"os_user": a.os_user,
|
||||
"tier": a.tier,
|
||||
"shell": a.shell,
|
||||
"login_locked": a.login_locked,
|
||||
"groups": list(a.groups),
|
||||
}
|
||||
for name, a in ds.accounts.items()
|
||||
},
|
||||
"ttyd_user_map": ds.ttyd_user_map,
|
||||
"dispatch": ds.dispatch,
|
||||
"ports": ds.ports,
|
||||
}
|
||||
|
||||
|
||||
def _main(argv: list[str]) -> int:
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Workstation roster engine")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
pv = sub.add_parser(
|
||||
"validate", help="exit 1 if roster tiers diverge from k8s_users"
|
||||
)
|
||||
pv.add_argument("--roster", required=True)
|
||||
pv.add_argument("--k8s-users-json", required=True, help="JSON map {k8s_user: tier}")
|
||||
pd = sub.add_parser("derive", help="emit desired state as JSON")
|
||||
pd.add_argument("--roster", required=True)
|
||||
pd.add_argument("--ports-json", required=True, help="JSON map {os_user: port}")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
roster = load_roster_file(args.roster)
|
||||
if args.cmd == "validate":
|
||||
with open(args.k8s_users_json, encoding="utf-8") as fh:
|
||||
issues = validate_tiers(roster, json.load(fh))
|
||||
for issue in issues:
|
||||
print(f"{issue.severity.upper()}: {issue.message}", file=sys.stderr)
|
||||
return 1 if has_blocking_errors(issues) else 0
|
||||
with open(args.ports_json, encoding="utf-8") as fh:
|
||||
desired = derive_desired_state(roster, json.load(fh))
|
||||
json.dump(_desired_state_to_dict(desired), sys.stdout, indent=2, sort_keys=True)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(_main(sys.argv[1:]))
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
#!/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] $*"; }
|
||||
|
||||
# 1) apt toolset (declarative manifest; comments/blank lines stripped)
|
||||
mapfile -t PKGS < <(grep -vE '^[[:space:]]*(#|$)' "$HERE/packages.txt")
|
||||
log "apt: ensuring ${#PKGS[@]} packages present"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y "${PKGS[@]}" >/dev/null
|
||||
|
||||
# 2) node >= 18 + claude-code (claude-code requires node >= 18)
|
||||
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
|
||||
command -v claude >/dev/null || { log "npm: installing @anthropic-ai/claude-code"; npm install -g @anthropic-ai/claude-code >/dev/null; }
|
||||
|
||||
# 3) kubelogin (kubectl oidc-login) system-wide — NOT the apt 'kubelogin' (= Azure tool)
|
||||
if [[ ! -x /usr/local/bin/kubelogin ]]; then
|
||||
log "kubelogin: installing int128/kubelogin"
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL -o "$tmp/kl.zip" https://github.com/int128/kubelogin/releases/latest/download/kubelogin_linux_amd64.zip
|
||||
( 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)"
|
||||
|
||||
# 6) deploy the roster-driven provisioner to /usr/local/bin (run hourly by
|
||||
# t3-provision-users.timer). Re-deployed here so its logic is reproducible.
|
||||
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
|
||||
|
||||
log "OK (idempotent)"
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Per-user Claude Code Workstation launcher (devvm). Lands the user in their OWN
|
||||
# ~/code clone (NOT a hardcoded /home/wizard/code) and names the Claude session
|
||||
# after the tmux session so /resume, the prompt box, and the terminal title line
|
||||
# up. Deployed via /etc/skel by setup-devvm.sh, so new accounts get it on
|
||||
# `useradd -m`. Existing users are repointed to this during their migration.
|
||||
echo ""
|
||||
echo " Welcome, $(id -un)! 🚀"
|
||||
echo ""
|
||||
echo " Starting Claude Code in $HOME/code ..."
|
||||
echo " (Right-click for tmux menu, or Ctrl+B then | or - to split)"
|
||||
echo ""
|
||||
|
||||
name_args=()
|
||||
if [ -n "${TMUX:-}" ]; then
|
||||
sess="$(tmux display-message -p '#{session_name}' 2>/dev/null)"
|
||||
[ -n "$sess" ] && name_args=(--name "$sess")
|
||||
fi
|
||||
|
||||
cd "$HOME/code" 2>/dev/null || cd "$HOME"
|
||||
|
||||
# Prefer the system-wide `claude` (installed by setup-devvm.sh); fall back to npx.
|
||||
launch() {
|
||||
if command -v claude >/dev/null 2>&1; then
|
||||
claude "$@"
|
||||
else
|
||||
npx @anthropic-ai/claude-code "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Deliberately not `exec` so we can branch on the exit code: clean quit ends the
|
||||
# pane (ttyd closes the terminal); a crash drops to a shell so the tmux session
|
||||
# isn't destroyed-and-recreated in a ttyd auto-reconnect loop.
|
||||
launch --dangerously-skip-permissions --model claude-opus-4-8 "${name_args[@]}"
|
||||
code=$?
|
||||
[ "$code" -eq 0 ] && exit 0
|
||||
|
||||
echo ""
|
||||
echo " claude exited abnormally (status $code). Dropping to a shell — your tmux session is preserved."
|
||||
echo " Re-launch any time with: ~/start-claude.sh"
|
||||
echo ""
|
||||
exec "${SHELL:-/bin/bash}" -l
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
# Workstation base tmux config (deployed to /etc/skel/.tmux.conf by
|
||||
# setup-devvm.sh; new accounts inherit it). Uses $HOME (expanded by the shell at
|
||||
# run time) so it works for ANY user — never a hardcoded /home/<name>.
|
||||
#
|
||||
# NOTE: the tmux-resurrect/continuum "persistence" block is owned by the separate
|
||||
# terminal-lobby tool, which appends its own managed section + installs tpm. This
|
||||
# base file intentionally omits it so a fresh account isn't left with broken
|
||||
# `run ~/.tmux/plugins/tpm/tpm` references before terminal-lobby runs.
|
||||
|
||||
# Launch the per-user Claude launcher in every new pane/window (lands in ~/code).
|
||||
set -g default-command "$HOME/start-claude.sh"
|
||||
|
||||
# Mouse support — click panes, drag to resize, scroll with wheel
|
||||
set -g mouse on
|
||||
|
||||
# Easy splits: Ctrl+b then | for vertical, - for horizontal
|
||||
bind | split-window -h -c "#{pane_current_path}"
|
||||
bind - split-window -v -c "#{pane_current_path}"
|
||||
bind c new-window -c "#{pane_current_path}"
|
||||
|
||||
# Right-click context menu — clickable actions popup
|
||||
bind -n MouseDown3Pane display-menu -T "#[align=centre]Terminal Menu" -x M -y M \
|
||||
"New Claude" w "new-window -c '#{pane_current_path}'" \
|
||||
"Split Horizontal" h "split-window -v -c '#{pane_current_path}'" \
|
||||
"Split Vertical" v "split-window -h -c '#{pane_current_path}'" \
|
||||
"" \
|
||||
"Shell" s "split-window -v -c '#{pane_current_path}' /bin/zsh" \
|
||||
"" \
|
||||
"Close Pane" x "confirm-before -p 'Close pane? (y/n)' kill-pane" \
|
||||
"Close Window" X "confirm-before -p 'Close window? (y/n)' kill-window" \
|
||||
"" \
|
||||
"Detach" d "detach-client"
|
||||
|
||||
# Clickable [+] button in the status bar — left-click to open the same menu
|
||||
set -g status-right '#[fg=black bg=green] [+] #[default] #[fg=cyan]Right-click for menu '
|
||||
set -g status-right-length 60
|
||||
bind -n MouseDown1StatusRight display-menu -T "#[align=centre]Terminal Menu" -x M -y S \
|
||||
"New Claude" w "new-window -c '#{pane_current_path}'" \
|
||||
"Split Horizontal" h "split-window -v -c '#{pane_current_path}'" \
|
||||
"Split Vertical" v "split-window -h -c '#{pane_current_path}'" \
|
||||
"" \
|
||||
"Shell" s "split-window -v -c '#{pane_current_path}' /bin/zsh" \
|
||||
"" \
|
||||
"Close Pane" x "confirm-before -p 'Close pane? (y/n)' kill-pane" \
|
||||
"Close Window" X "confirm-before -p 'Close window? (y/n)' kill-window"
|
||||
|
||||
# Status bar styling + 1-based numbering
|
||||
set -g status-style 'bg=colour235 fg=colour136'
|
||||
set -g status-left '#[fg=green][#S] '
|
||||
set -g base-index 1
|
||||
setw -g pane-base-index 1
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
"""Unit tests for the pure roster derivation + offboarding-diff engine.
|
||||
|
||||
These exercise external behaviour only (parse -> validate -> derive -> diff);
|
||||
no host I/O is touched. Mirrors the pure-core pytest style used elsewhere in
|
||||
the monorepo. See PRD ViktorBarzin/infra#9 (modules #1 roster engine, #5
|
||||
offboarding diff).
|
||||
"""
|
||||
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
import roster_engine as eng
|
||||
|
||||
|
||||
def _roster(yaml_text: str) -> "eng.Roster":
|
||||
return eng.load_roster(textwrap.dedent(yaml_text))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# load_roster: parsing + structural validation (module #1)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parses_user_fields_and_tier():
|
||||
r = _roster(
|
||||
"""
|
||||
users:
|
||||
emo: {authentik_user: emil.barzin, k8s_user: emo, tier: power-user}
|
||||
"""
|
||||
)
|
||||
u = r.users["emo"]
|
||||
assert u.os_user == "emo"
|
||||
assert u.authentik_user == "emil.barzin"
|
||||
assert u.k8s_user == "emo"
|
||||
assert u.tier == "power-user"
|
||||
assert u.namespaces == ()
|
||||
|
||||
|
||||
def test_namespace_owner_carries_namespaces():
|
||||
r = _roster(
|
||||
"""
|
||||
users:
|
||||
ancamilea: {authentik_user: ancaelena98, k8s_user: anca,
|
||||
tier: namespace-owner, namespaces: [plotting-book]}
|
||||
"""
|
||||
)
|
||||
assert r.users["ancamilea"].namespaces == ("plotting-book",)
|
||||
|
||||
|
||||
def test_admin_tier_is_accepted():
|
||||
r = _roster(
|
||||
"users: {wizard: {authentik_user: vbarzin, k8s_user: wizard, tier: admin}}"
|
||||
)
|
||||
assert r.users["wizard"].tier == "admin"
|
||||
|
||||
|
||||
def test_rejects_unknown_tier():
|
||||
with pytest.raises(eng.RosterError, match="tier"):
|
||||
_roster("users: {bob: {authentik_user: b, k8s_user: b, tier: wizard-king}}")
|
||||
|
||||
|
||||
def test_rejects_missing_required_field():
|
||||
with pytest.raises(eng.RosterError, match="authentik_user"):
|
||||
_roster("users: {bob: {k8s_user: b, tier: power-user}}")
|
||||
|
||||
|
||||
def test_namespace_owner_requires_namespaces():
|
||||
with pytest.raises(eng.RosterError, match="namespace"):
|
||||
_roster("users: {bob: {authentik_user: b, k8s_user: b, tier: namespace-owner}}")
|
||||
|
||||
|
||||
def test_non_namespace_owner_must_not_set_namespaces():
|
||||
with pytest.raises(eng.RosterError, match="namespace"):
|
||||
_roster(
|
||||
"users: {bob: {authentik_user: b, k8s_user: b, tier: power-user, "
|
||||
"namespaces: [x]}}"
|
||||
)
|
||||
|
||||
|
||||
def test_empty_roster_is_valid():
|
||||
assert _roster("users: {}").users == {}
|
||||
|
||||
|
||||
def test_missing_users_key_is_valid_empty():
|
||||
assert _roster("{}").users == {}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# validate_tiers: roster tier vs live k8s_users (fail-loud, module #1)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_ok_when_tiers_match():
|
||||
r = _roster(
|
||||
"users: {ancamilea: {authentik_user: a, k8s_user: anca, "
|
||||
"tier: namespace-owner, namespaces: [plotting-book]}}"
|
||||
)
|
||||
assert eng.validate_tiers(r, {"anca": "namespace-owner"}) == []
|
||||
|
||||
|
||||
def test_validate_flags_tier_mismatch_as_error():
|
||||
# roster says power-user, cluster says namespace-owner -> a real conflict -> ERROR (abort).
|
||||
r = _roster(
|
||||
"users: {ancamilea: {authentik_user: a, k8s_user: anca, tier: power-user}}"
|
||||
)
|
||||
issues = eng.validate_tiers(r, {"anca": "namespace-owner"})
|
||||
assert len(issues) == 1
|
||||
assert issues[0].severity == "error"
|
||||
assert issues[0].os_user == "ancamilea"
|
||||
assert "power-user" in issues[0].message and "namespace-owner" in issues[0].message
|
||||
|
||||
|
||||
def test_validate_flags_netnew_absent_as_warn():
|
||||
# emo is power-user in the roster but has no k8s_users entry yet. Onboarding the
|
||||
# workstation should still proceed; the kubectl grant is pending -> WARN, not error.
|
||||
r = _roster("users: {emo: {authentik_user: e, k8s_user: emo, tier: power-user}}")
|
||||
issues = eng.validate_tiers(r, {})
|
||||
assert len(issues) == 1
|
||||
assert issues[0].severity == "warn"
|
||||
assert "emo" in issues[0].message and "k8s_users" in issues[0].message
|
||||
|
||||
|
||||
def test_validate_skips_admin_tier():
|
||||
# wizard (admin) is cluster-admin via a separate mechanism, not k8s_users.
|
||||
r = _roster(
|
||||
"users: {wizard: {authentik_user: vbarzin, k8s_user: wizard, tier: admin}}"
|
||||
)
|
||||
assert eng.validate_tiers(r, {}) == []
|
||||
|
||||
|
||||
def test_has_blocking_errors_distinguishes_mismatch_from_absent():
|
||||
mismatch = _roster(
|
||||
"users: {ancamilea: {authentik_user: a, k8s_user: anca, tier: power-user}}"
|
||||
)
|
||||
absent = _roster(
|
||||
"users: {emo: {authentik_user: e, k8s_user: emo, tier: power-user}}"
|
||||
)
|
||||
assert (
|
||||
eng.has_blocking_errors(
|
||||
eng.validate_tiers(mismatch, {"anca": "namespace-owner"})
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert eng.has_blocking_errors(eng.validate_tiers(absent, {})) is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# derive_desired_state: accounts, sticky ports, ttyd map, dispatch (module #1)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
THREE = """
|
||||
users:
|
||||
wizard: {authentik_user: vbarzin, k8s_user: wizard, tier: admin}
|
||||
emo: {authentik_user: emil.barzin, k8s_user: emo, tier: power-user}
|
||||
ancamilea: {authentik_user: ancaelena98, k8s_user: anca, tier: namespace-owner, namespaces: [plotting-book]}
|
||||
"""
|
||||
|
||||
LIVE_PORTS = {"wizard": 3773, "emo": 3774, "ancamilea": 3775}
|
||||
|
||||
|
||||
def test_derive_preserves_existing_sticky_ports():
|
||||
ds = eng.derive_desired_state(_roster(THREE), LIVE_PORTS)
|
||||
assert ds.ports == {"wizard": 3773, "emo": 3774, "ancamilea": 3775}
|
||||
|
||||
|
||||
def test_derive_allocates_next_free_port_for_new_user():
|
||||
ds = eng.derive_desired_state(_roster(THREE), {"wizard": 3773})
|
||||
# emo + ancamilea are new -> next free from 3773 skipping the used 3773
|
||||
assert ds.ports["wizard"] == 3773
|
||||
assert sorted([ds.ports["emo"], ds.ports["ancamilea"]]) == [3774, 3775]
|
||||
|
||||
|
||||
def test_derive_dispatch_keyed_by_authentik_user():
|
||||
ds = eng.derive_desired_state(_roster(THREE), LIVE_PORTS)
|
||||
assert ds.dispatch == {
|
||||
"vbarzin": {"os_user": "wizard", "port": 3773},
|
||||
"emil.barzin": {"os_user": "emo", "port": 3774},
|
||||
"ancaelena98": {"os_user": "ancamilea", "port": 3775},
|
||||
}
|
||||
|
||||
|
||||
def test_derive_ttyd_map_has_one_mapping_per_user():
|
||||
ds = eng.derive_desired_state(_roster(THREE), LIVE_PORTS)
|
||||
body = [
|
||||
line
|
||||
for line in ds.ttyd_user_map.splitlines()
|
||||
if line.strip() and not line.lstrip().startswith("#")
|
||||
]
|
||||
assert set(body) == {"vbarzin=wizard", "emil.barzin=emo", "ancaelena98=ancamilea"}
|
||||
|
||||
|
||||
def test_derive_accounts_assign_tier_groups_and_shell():
|
||||
ds = eng.derive_desired_state(_roster(THREE), LIVE_PORTS)
|
||||
assert ds.accounts["wizard"].groups == ("code-shared", "docker", "sudo")
|
||||
assert ds.accounts["emo"].groups == ()
|
||||
assert ds.accounts["ancamilea"].groups == ()
|
||||
assert ds.accounts["emo"].shell == "/bin/zsh"
|
||||
|
||||
|
||||
def test_derive_is_deterministic():
|
||||
r = _roster(THREE)
|
||||
assert eng.derive_desired_state(r, LIVE_PORTS) == eng.derive_desired_state(
|
||||
r, LIVE_PORTS
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# groups_to_add: the additive-only invariant (module #1)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_groups_to_add_returns_only_missing():
|
||||
assert eng.groups_to_add(("sudo", "docker", "code-shared"), ("docker",)) == [
|
||||
"code-shared",
|
||||
"sudo",
|
||||
]
|
||||
|
||||
|
||||
def test_groups_to_add_never_proposes_removal_of_extra_groups():
|
||||
# emo currently has code-shared+docker (legacy). A power-user reconcile wants
|
||||
# no groups -> must NOT strip anything (additive-only invariant).
|
||||
assert eng.groups_to_add((), ("code-shared", "docker")) == []
|
||||
|
||||
|
||||
def test_groups_to_add_idempotent_when_all_present():
|
||||
assert eng.groups_to_add(("sudo",), ("sudo", "docker")) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# offboarding diff: staged plan, destructive never auto (module #5)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_deprovision_is_old_minus_new():
|
||||
old = _roster(THREE)
|
||||
new = _roster(
|
||||
"""
|
||||
users:
|
||||
wizard: {authentik_user: vbarzin, k8s_user: wizard, tier: admin}
|
||||
emo: {authentik_user: emil.barzin, k8s_user: emo, tier: power-user}
|
||||
"""
|
||||
)
|
||||
assert eng.to_deprovision(old, new) == ["ancamilea"]
|
||||
|
||||
|
||||
def test_to_deprovision_empty_when_nothing_removed():
|
||||
r = _roster(THREE)
|
||||
assert eng.to_deprovision(r, r) == []
|
||||
|
||||
|
||||
def test_offboard_plan_reversible_cut_targets_exactly_the_removed_user():
|
||||
old = _roster(THREE)
|
||||
new = _roster(
|
||||
"users: {wizard: {authentik_user: vbarzin, k8s_user: wizard, tier: admin}}"
|
||||
)
|
||||
plan = eng.offboard_plan(old, new, include_destructive=False)
|
||||
cut_users = {a.os_user for a in plan}
|
||||
assert cut_users == {"emo", "ancamilea"}
|
||||
assert all(a.reversible for a in plan)
|
||||
|
||||
|
||||
def test_offboard_plan_excludes_destructive_by_default():
|
||||
old = _roster(THREE)
|
||||
new = _roster(
|
||||
"users: {wizard: {authentik_user: vbarzin, k8s_user: wizard, tier: admin}}"
|
||||
)
|
||||
auto = eng.offboard_plan(old, new, include_destructive=False)
|
||||
assert all(a.kind != "userdel_archive" for a in auto)
|
||||
|
||||
|
||||
def test_offboard_plan_includes_destructive_only_when_explicitly_requested():
|
||||
old = _roster(THREE)
|
||||
new = _roster(
|
||||
"users: {wizard: {authentik_user: vbarzin, k8s_user: wizard, tier: admin}}"
|
||||
)
|
||||
full = eng.offboard_plan(old, new, include_destructive=True)
|
||||
destructive = [a for a in full if a.kind == "userdel_archive"]
|
||||
assert {a.os_user for a in destructive} == {"emo", "ancamilea"}
|
||||
assert all(not a.reversible for a in destructive)
|
||||
Loading…
Add table
Add a link
Reference in a new issue