From e45e7357b3b4f335caf825f53ff640fe104919fc Mon Sep 17 00:00:00 2001 From: Derek Date: Wed, 26 Aug 2026 15:53:13 +1000 Subject: [PATCH 1/2] fix: apply the user-level settings for every human account, not just one A box with more than one person on it only ever got the dotfiles for whoever was named. Everyone else got the system-wide half and nothing else -- no shell config, no ~/.cargo, no ~/.local/bin, no dconf, no container stacks -- and the run reported success either way. A desktop image with both a provisioning account and a real user is exactly that shape. install.sh now discovers the accounts and runs one pass per user. Discovery skips root, the system ranges (uid < 1000, the systemd range above 60000, shells of nologin/false/sync), and the cloud image's own account. That last one is read from system_info.default_user.name in /etc/cloud/cloud.cfg rather than matched by name, because it is `ubuntu` on an Ubuntu image and `cloud-user` on a Red Hat one; it exists to provision the machine, not to work in. `--users a,b` names any set instead, that account included. One pass per user rather than a loop inside the roles. There are roughly 350 user-scoped references across 49 files, so a per-task loop or a `user-scope` tag would eventually miss one silently -- which is the failure mode this area already has a history of. Looping the invocation cannot miss a task by construction, and the system-wide tasks are idempotent, so later passes no-op on everything except that user's own files. The playbook itself is unchanged: it still takes one hyperi_target_user per run. Anything driving ansible-playbook directly writes its own loop, which the docs now show. Verified on a real two-account host. With both accounts seeded to a 12h clock, a pass for each leaves both at 24h -- the second pass does not undo the first. Discovery returns `hyperi` on one box and `derek` on the other, correctly excluding `ubuntu` in both cases, and `tools/ci/run-tests.sh` is green. --- README.md | 44 +++++++++++++++++----- docs/install-matrix.md | 41 +++++++++++++++++++-- install.sh | 84 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 146 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 494271e..24f2874 100644 --- a/README.md +++ b/README.md @@ -108,23 +108,49 @@ GNOME already holds** -- it mints a password only when Remote Login has none, so re-running it cannot lock out whoever is already connecting. To rotate deliberately, delete `/etc/hyperi/rdp-credentials` and re-run. -### Which user gets the user-level settings +### Which users get the user-level settings Most of what this installs is system-wide, but some of it is per-user: shell config, `~/.cargo`, `~/.local/bin`, dconf/GNOME settings, the Arcane and -local-services stacks. Those follow **`hyperi_target_user`**. +local-services stacks. -It defaults to whoever is running the install, which is the right answer on a -laptop. It is the wrong answer on a fleet machine reached over SSH as a service -account, because that account's home is not the desktop: +`./install.sh` applies those for **every account a person actually works in**. +Three kinds are skipped: + +- `root` +- system accounts -- uid below 1000, the systemd range above 60000, and + anything shelled to `nologin`/`false`/`sync` +- **the cloud image's own account** -- `ubuntu` on an Ubuntu cloud image, + `cloud-user` on a Red Hat one. It exists to provision the machine, not to + work in, so it gets the system-wide setup and none of the dotfiles + +That last one is read from `system_info.default_user.name` in +`/etc/cloud/cloud.cfg`, which is where cloud-init declares it, rather than +matching on the name. + +Name any set yourself when that is not what you want -- including the cloud +account: + +```bash +./install.sh --users hyperi,ubuntu +``` + +Each user gets their own pass. The system-wide work is idempotent, so it +happens once in effect and later passes no-op; one user's settings never +overwrite another's. + +Driving Ansible directly instead of `install.sh`? It takes one user per run, so +loop it: ```bash -# Fleet machine: connect as the service account, install for the desktop user -ansible-playbook ... -e hyperi_target_user=hyperi +for u in ubuntu hyperi; do + ansible-playbook ... -e hyperi_target_user="$u" +done ``` -Get this wrong and the run still reports success -- the settings simply land in -the service account's home where nobody sees them. +That matters most on a fleet machine reached over SSH as a service account, +because that account's home is not the desktop. Get it wrong and the run still +reports success -- the settings simply land where nobody sees them. ## What Gets Installed diff --git a/docs/install-matrix.md b/docs/install-matrix.md index 6f5f1ac..cc478a9 100644 --- a/docs/install-matrix.md +++ b/docs/install-matrix.md @@ -365,13 +365,48 @@ is the most expensive mistake available here. `hyperi_target_user` names them; `actual_user` and `user_home` derive from it, and every user-scoped task keys off those. -They are the same person on a laptop, which is why the default (the invoking -user) is right there and wrong on a fleet machine: +They are the same person on a laptop, which is why the default is right there +and wrong on a fleet machine. + +### One run, one user -- and how the loop works + +**The playbook applies user-level settings for exactly ONE user per run.** +There is no loop inside the roles, and that is deliberate: roughly 350 +user-scoped references across 49 files means a per-task loop or a +`user-scope` tag would eventually miss one silently, which is the failure mode +this whole area already has a history of. + +`install.sh` loops instead, once per user: + +| Selection | Behaviour | +|---|---| +| default | every real login account, discovered from `/etc/passwd` | +| `--users a,b` | exactly those, in that order | + +Discovery skips three kinds of account: `root`; system accounts (uid below 1000, +the systemd range above 60000, shells of `nologin`/`false`/`sync`); and **the +cloud image's own account**, which exists to provision the machine rather than +to work in. + +That last one is read from `system_info.default_user.name` in +`/etc/cloud/cloud.cfg` rather than matched by name, because it is `ubuntu` on an +Ubuntu image and `cloud-user` on a Red Hat one. An override dropped in +`cloud.cfg.d` is not consulted -- use `--users` on a machine that does that. +`--users` is also how you deliberately include the cloud account. + +Driving Ansible directly (Packer, hyperi-infra) means writing the loop +yourself: ```bash -ansible-playbook ... -e hyperi_target_user=hyperi +for u in ubuntu hyperi; do + ansible-playbook ... -e hyperi_target_user="$u" +done ``` +The system-wide tasks are idempotent, so the second and later passes no-op on +everything except that user's own files, and no user's settings overwrite +another's. + **Getting it wrong reports success.** A task that writes to a home directory as the connecting user writes to the WRONG home and exits 0; the tools land where nobody looks. `ansible.builtin.file` is worse -- it cannot read across into the diff --git a/install.sh b/install.sh index 26656eb..af9fc8f 100755 --- a/install.sh +++ b/install.sh @@ -120,6 +120,9 @@ EXAMPLES: Install the RDP server (GNOME Remote Login) for inbound access: ./install.sh --tags rdp-server + Apply the user-level settings for named users instead of the detected ones: + ./install.sh --users hyperi,ubuntu + Dry-run to see what would change: ./install.sh --check @@ -130,6 +133,11 @@ NOTES: random password, shown once, and never overwrites credentials already set - Use --tags-exclude to skip specific tags within a chosen group - Use --list-apps to see every per-app sub-tag for granular installs + - User-level settings (shell config, ~/.cargo, ~/.local, dconf, the container + stacks) are applied for every account a person works in. Skipped by default: + root, the system ranges, and the cloud image's own account (ubuntu, + cloud-user). Name any set yourself with --users, that account included. + System-wide work happens once either way. EOF exit 0 } @@ -263,12 +271,37 @@ append_extra_var() { fi } +# cloud-init DECLARES the account it created, and the name differs per image +# family (ubuntu, cloud-user, ec2-user), so read it rather than guessing. An +# override dropped in cloud.cfg.d is not consulted -- name the users with +# --users on a machine that does that. +cloud_image_user() { + [[ -r /etc/cloud/cloud.cfg ]] || return 0 + awk '/^[[:space:]]*default_user:/ { in_block = 1; next } + in_block && /^[[:space:]]*name:/ { + sub(/^[[:space:]]*name:[[:space:]]*/, ""); print; exit + }' /etc/cloud/cloud.cfg +} + +# Every account a person actually works in: root, the system ranges and the +# image's own provisioning account are all out. uid 60000 is the ceiling because +# systemd allocates its own users above it. +discover_target_users() { + local cloud_user + cloud_user="$(cloud_image_user)" + getent passwd | awk -F: -v cloud="$cloud_user" ' + $3 >= 1000 && $3 < 60000 && + $1 != "root" && $1 != cloud && + $7 !~ /(nologin|\/false|\/sync)$/ { print $1 }' | sort | tr '\n' ' ' +} + # Parse arguments ANSIBLE_CHECK="" ANSIBLE_TAGS="" ANSIBLE_SKIP_TAGS="" ANSIBLE_EXTRA_VARS="" GIT_BRANCH="main" +TARGET_USERS="" while [[ $# -gt 0 ]]; do case $1 in @@ -371,6 +404,10 @@ while [[ $# -gt 0 ]]; do append_extra_var "hyperi_pinned=true" shift ;; + --users) + TARGET_USERS="$(printf '%s' "$2" | tr ',' ' ')" + shift 2 + ;; --list-apps) list_apps ;; @@ -573,20 +610,45 @@ print_info "Command: $ANSIBLE_BIN playbooks/main.yml -i inventories/localhost/in cd ansible || exit 1 +# Settle who gets the user-level settings -- shell config, ~/.cargo, ~/.local, +# dconf, the container stacks. macOS never separates the desktop user from the +# installing user, so there is nothing to discover there. +if [[ -z "$TARGET_USERS" ]]; then + if [[ "$OS_FAMILY" == "macos" ]]; then + TARGET_USERS="$(id -un)" + else + TARGET_USERS="$(discover_target_users)" + fi +fi + +# A machine with no qualifying account still has whoever is running this. +if [[ -z "${TARGET_USERS// /}" ]]; then + TARGET_USERS="${SUDO_USER:-$(id -un)}" +fi + +print_info "User-level settings will be applied for: $TARGET_USERS" + +# One pass per user rather than a loop inside the roles: the system-wide tasks +# are idempotent and no-op on later passes, and nothing user-scoped can be +# silently missed the way a forgotten loop or tag would miss it. # The EXIT trap set above removes the temp venv on any outcome. Run the # playbook inside the `if` condition so set -e does not abort before we can # report a friendly failure (and the trap still fires on exit). -# shellcheck disable=SC2086 -if ! "$ANSIBLE_BIN" \ - playbooks/main.yml \ - -i inventories/localhost/inventory.yml \ - $ANSIBLE_CHECK \ - $ANSIBLE_TAGS \ - $ANSIBLE_SKIP_TAGS_ARG \ - $ANSIBLE_EXTRA_VARS; then - print_error "Ansible playbook failed" - exit 1 -fi +for target_user in $TARGET_USERS; do + print_info "Applying for $target_user ..." + # shellcheck disable=SC2086 + if ! "$ANSIBLE_BIN" \ + playbooks/main.yml \ + -i inventories/localhost/inventory.yml \ + $ANSIBLE_CHECK \ + $ANSIBLE_TAGS \ + $ANSIBLE_SKIP_TAGS_ARG \ + $ANSIBLE_EXTRA_VARS \ + -e "hyperi_target_user=$target_user"; then + print_error "Ansible playbook failed for $target_user" + exit 1 + fi +done print_success "Hyperi Developer Environment installation complete!" print_info "" From 16c9ec6f41d5e9ed01a4e9945d2214a2b3848cd2 Mon Sep 17 00:00:00 2001 From: Derek Date: Wed, 26 Aug 2026 16:00:16 +1000 Subject: [PATCH 2/2] fix: stop rather than guess when no account qualifies for user-level settings Falling back to whoever invoked the install would write dotfiles into root or the image's own provisioning account on exactly the machines the criteria exist to protect. Name the account with --users instead. --- README.md | 6 ++++++ docs/install-matrix.md | 6 ++++++ install.sh | 9 +++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 24f2874..8d9c6bd 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,12 @@ account: ./install.sh --users hyperi,ubuntu ``` +**If no account qualifies, nothing is applied and the run stops** with the +reason. That happens on a machine holding only `root`, system accounts and the +image's own -- a fresh cloud image, typically. It does not guess: writing +dotfiles into `root` or the provisioning account is the outcome the criteria +exist to prevent, so name the account you meant with `--users`. + Each user gets their own pass. The system-wide work is idempotent, so it happens once in effect and later passes no-op; one user's settings never overwrite another's. diff --git a/docs/install-matrix.md b/docs/install-matrix.md index cc478a9..2c306c1 100644 --- a/docs/install-matrix.md +++ b/docs/install-matrix.md @@ -394,6 +394,12 @@ Ubuntu image and `cloud-user` on a Red Hat one. An override dropped in `cloud.cfg.d` is not consulted -- use `--users` on a machine that does that. `--users` is also how you deliberately include the cloud account. +**No qualifying account means nothing is applied and the run stops**, naming the +reason. A fresh cloud image holding only `root`, system accounts and its own is +exactly that case. There is no fallback to the invoking user on purpose -- +falling back would write dotfiles into `root` or the provisioning account, which +is what the criteria exist to prevent. + Driving Ansible directly (Packer, hyperi-infra) means writing the loop yourself: diff --git a/install.sh b/install.sh index af9fc8f..8532060 100755 --- a/install.sh +++ b/install.sh @@ -621,9 +621,14 @@ if [[ -z "$TARGET_USERS" ]]; then fi fi -# A machine with no qualifying account still has whoever is running this. +# No qualifying account means no user-level settings, full stop. Falling back to +# whoever invoked this would write dotfiles into root or the image's own +# provisioning account, which is the outcome the criteria exist to prevent. if [[ -z "${TARGET_USERS// /}" ]]; then - TARGET_USERS="${SUDO_USER:-$(id -un)}" + print_error "No account qualifies for the user-level settings" + print_info "Skipped: root, system accounts, and the cloud image's own account" + print_info "Name one explicitly if that is not what you want: --users " + exit 1 fi print_info "User-level settings will be applied for: $TARGET_USERS"