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:
Viktor Barzin 2026-06-09 08:42:26 +00:00
parent 05b50d2b96
commit 6d224861c4
1168 changed files with 120 additions and 358547 deletions

View file

@ -1,3 +0,0 @@
__pycache__/
.pytest_cache/
*.pyc

View file

@ -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."
}

View file

@ -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.

View file

@ -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

View file

@ -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:]))

View file

@ -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)"

View file

@ -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

View file

@ -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

View file

@ -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)