From 2e5af5dc0e5187324ce687ead3ee1f1a373afa8d Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 10 Jun 2026 09:41:38 +0000 Subject: [PATCH] workstation: keep non-admin infra clones fresh (hourly + at launch) [ci skip] Non-admins (emo) need current master without manual pulls. Two layers: - t3-provision-users reconcile gains refresh_locked_clone: fetch all remotes + ff-only master, guarded (on master, clean tree, upstream set); dirty/diverged clones are left alone with a WARN. - start-claude.sh freshens ~/code at session launch, 15s-capped so an offline remote never delays the session. Verified live on emo's clone: stale clone ff'd to tip by the reconciler; launcher snippet ff's when clean and refuses while a dirty file exists. Deployed to /usr/local/bin/t3-provision-users, /etc/skel/start-claude.sh, and emo's launcher. Co-Authored-By: Claude Fable 5 --- docs/architecture/multi-tenancy.md | 2 +- scripts/t3-provision-users.sh | 22 +++++++++++++++++++++- scripts/workstation/skel/start-claude.sh | 13 +++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/architecture/multi-tenancy.md b/docs/architecture/multi-tenancy.md index 14744c89..387611b7 100644 --- a/docs/architecture/multi-tenancy.md +++ b/docs/architecture/multi-tenancy.md @@ -543,7 +543,7 @@ Separate from the in-cluster namespace-owner model above, the **devvm** (`10.0.1 **Config inheritance (live):** wizard authors the base (his chezmoi-versioned `~/.claude`). Two native layers carry it to every user — the enforced org `claudeMd` in `/etc/claude-code/managed-settings.json` (top precedence, all sessions) and per-user `~/.claude/{skills,rules,…}` **symlinks** to the base (seeded via `/etc/skel`; edits propagate live). Secrets stay per-user at mode 600, never symlinked. -**Infra access:** non-admins get their own **writable, git-crypt-LOCKED** clone of the (public) infra repo at `~/code` — code/docs plaintext, secret files (`*.tfvars`, `secrets/**`) stay ciphertext. The provisioner clones anonymously from the public GitHub mirror; **contribute access is wired per-user on top** (see below). The apply boundary still holds (`scripts/tg apply` needs an admin Vault token + cluster RBAC), but **pushing `master` is NOT inert** — the Forgejo→Woodpecker webhook fires `.woodpecker/default.yml` (`event: push, branch: master`, `require_approval: forks` only), which terragrunt-applies changed stacks. `master` is therefore **branch-protected on Forgejo** (push + merge whitelists = `viktor`, deploy keys allowed): non-admins contribute via `/` branches + PRs, and only an admin merge lands (and thus applies) their change. +**Infra access:** non-admins get their own **writable, git-crypt-LOCKED** clone of the (public) infra repo at `~/code` — code/docs plaintext, secret files (`*.tfvars`, `secrets/**`) stay ciphertext. The provisioner clones anonymously from the public GitHub mirror; **contribute access is wired per-user on top** (see below). The apply boundary still holds (`scripts/tg apply` needs an admin Vault token + cluster RBAC), but **pushing `master` is NOT inert** — the Forgejo→Woodpecker webhook fires `.woodpecker/default.yml` (`event: push, branch: master`, `require_approval: forks` only), which terragrunt-applies changed stacks. `master` is therefore **branch-protected on Forgejo** (push + merge whitelists = `viktor`, deploy keys allowed): non-admins contribute via `/` branches + PRs, and only an admin merge lands (and thus applies) their change. **Clones stay fresh automatically** (2026-06-10): the hourly `t3-provision-users` reconcile runs `refresh_locked_clone` (fetch all remotes + fast-forward `master`, ONLY when on master with a clean tree and an upstream — dirty trees and local commits are left alone with a WARN), and `start-claude.sh` does the same freshen at session launch (15s-capped so an offline remote never stalls the session). **Contribute access (per non-admin, manual — the anca/tripit PAT precedent):** 1. Add their Forgejo user as a **write** collaborator on `viktor/infra` (`PUT /api/v1/repos/viktor/infra/collaborators/`). diff --git a/scripts/t3-provision-users.sh b/scripts/t3-provision-users.sh index 37689153..52a27015 100644 --- a/scripts/t3-provision-users.sh +++ b/scripts/t3-provision-users.sh @@ -45,6 +45,25 @@ install_locked_clone() { runuser -u "$user" -- git -C "$home/code" checkout --quiet master } +# Keep an EXISTING non-admin clone fresh (the admin's tree is never touched): fetch +# all remotes, then fast-forward master only when that is provably safe — on master, +# clean tree, upstream configured. Never rebases/merges; a non-ff master (local +# commits) is the user's to reconcile and is only WARNed about. Fetch failures +# (offline, missing credentials) are non-fatal: freshness is best-effort. +refresh_locked_clone() { + local user="$1" home + home="$(getent passwd "$user" | cut -d: -f6)" + [[ -n "$home" && -d "$home/code/.git" ]] || return 0 + if [[ "$DRY_RUN" == 1 ]]; then echo "[dry-run] refresh clone -> $user:$home/code"; return 0; fi + runuser -u "$user" -- env GIT_TERMINAL_PROMPT=0 git -C "$home/code" fetch --all --prune --quiet 2>/dev/null \ + || { log "WARN: clone fetch failed for $user (offline/credentials?) — skipped"; return 0; } + [[ "$(runuser -u "$user" -- git -C "$home/code" symbolic-ref --short -q HEAD)" == master ]] || return 0 + [[ -z "$(runuser -u "$user" -- git -C "$home/code" status --porcelain)" ]] || return 0 + runuser -u "$user" -- git -C "$home/code" rev-parse --verify -q 'master@{upstream}' >/dev/null || return 0 + runuser -u "$user" -- git -C "$home/code" merge --ff-only 'master@{upstream}' >/dev/null 2>&1 \ + || log "WARN: $user master not fast-forwardable (local commits?) — left as-is" +} + # Per-user OIDC kubeconfig (kubelogin/PKCE — the `kubernetes` Authentik client is # public, no secret). Identical for all users: identity comes from each user's own # interactive OIDC login, which the apiserver maps (email claim) to their RBAC. @@ -177,8 +196,9 @@ while IFS=$'\t' read -r os_user tier shell groups_csv; do log "add $os_user -> group $g"; run gpasswd -a "$os_user" "$g" >/dev/null done fi - if [[ "$tier" != admin ]]; then # non-admins: locked clone + kubeconfig + shared Claude token + if [[ "$tier" != admin ]]; then # non-admins: locked clone (kept fresh) + kubeconfig + shared Claude token install_locked_clone "$os_user" + refresh_locked_clone "$os_user" install_user_kubeconfig "$os_user" install_user_claude_token "$os_user" fi diff --git a/scripts/workstation/skel/start-claude.sh b/scripts/workstation/skel/start-claude.sh index fa21aa36..1a630366 100755 --- a/scripts/workstation/skel/start-claude.sh +++ b/scripts/workstation/skel/start-claude.sh @@ -19,6 +19,19 @@ fi cd "$HOME/code" 2>/dev/null || cd "$HOME" +# Freshen ~/code at session start so the user begins on current upstream state +# (the hourly t3-provision-users reconcile does the same in the background). +# Fast-forward only, and only when safe (on master + clean tree); hard 15s cap so +# an offline remote never stalls the launch. No-op for repos without remotes. +if [ -d "$HOME/code/.git" ]; then + GIT_TERMINAL_PROMPT=0 timeout 15 git -C "$HOME/code" fetch --all --prune --quiet 2>/dev/null || true + if [ "$(git -C "$HOME/code" symbolic-ref --short -q HEAD)" = master ] \ + && [ -z "$(git -C "$HOME/code" status --porcelain 2>/dev/null)" ] \ + && git -C "$HOME/code" rev-parse --verify -q 'master@{upstream}' >/dev/null 2>&1; then + git -C "$HOME/code" merge --ff-only 'master@{upstream}' >/dev/null 2>&1 || true + fi +fi + # Prefer the system-wide `claude` (installed by setup-devvm.sh); fall back to npx. launch() { if command -v claude >/dev/null 2>&1; then