diff --git a/README.md b/README.md index 494271e..8d9c6bd 100644 --- a/README.md +++ b/README.md @@ -108,23 +108,55 @@ 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 +``` + +**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. + +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..2c306c1 100644 --- a/docs/install-matrix.md +++ b/docs/install-matrix.md @@ -365,13 +365,54 @@ 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. + +**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: ```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..8532060 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,50 @@ 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 + +# 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 + 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" + +# 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 ""