From 207ebcdb37cf9844f1b9a569bc41af6c55740f8c Mon Sep 17 00:00:00 2001 From: Lily Dayton <43729618+ldayton@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:44:40 +0000 Subject: [PATCH] Install everything by default: Homebrew, the Brewfile, the shell, Claude Code The role only ever shared a Homebrew prefix somebody else had already put there, and on a stock cloud image -- which has none -- every brew task skipped as a green tick. The account came up without gh, zellij, or anything else this repo is about, and the host preset had to downgrade the shell to /bin/bash to get past the guard. A standard run should provision the standard setup, so the role now installs the prefix when it is missing, installs the login shell before creating the account, and the Brewfile and Claude Code default to on. The separate-filesystem check flips to off, because the default home is on the root volume and demanding otherwise failed every ordinary box. Claude-Session: https://claude.ai/code/session_01SW9Cwp9MTZBr5Yk577frw6 --- ansible/hosts/lily.yml | 18 ++-- ansible/roles/dev_user/defaults/main.yml | 44 ++++++--- ansible/roles/dev_user/tasks/main.yml | 115 +++++++++++++++++++++-- ansible/site.yml | 7 +- 4 files changed, 150 insertions(+), 34 deletions(-) diff --git a/ansible/hosts/lily.yml b/ansible/hosts/lily.yml index 6b7207a..78e7215 100644 --- a/ansible/hosts/lily.yml +++ b/ansible/hosts/lily.yml @@ -4,21 +4,19 @@ # this account can log into. # # ansible-playbook -i inventory.ini site.yml -e @hosts/lily.yml +# +# Everything else this box needs is now the role's own default -- Homebrew, the Brewfile, the +# zsh those two put in the prefix, Claude Code. This file used to switch three of them off and +# downgrade the shell to /bin/bash, because the role could not install Homebrew and a cloud +# image has none. It can now, so the overrides are gone rather than being carried as a preset +# that quietly provisioned less than the role advertises. dev_user_authorized_keys: - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHEHSv9U28JSaick1k4nTgA5Fb4nDUM1RYqv/uIlxBZ+ lily.dayton@mac" - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA3frltPWfDQwvR8Cb4CnsSjR605S/lSLTsI0IG2aG8D lily.dayton@wsl" -# A cloud image has neither Homebrew nor zsh, and the role's default shell lives inside the -# brew prefix -- so the shell guard refuses every box that has no prefix. -dev_user_shell: /bin/bash -dev_user_brewfile: false - -# The home lands on the root volume. A cloud box's separate filesystem, where it has one, is -# the instance store its own workload already claimed. -dev_user_home_require_separate_fs: false - -dev_user_claude_code: true +# The private overlay stays off here: it is named in an untracked vars file, not in a preset +# that lives in a public repo. See private.yml.example. dev_user_overlay_repo: "" # Root on a box that belongs to other things is a grant worth naming per host, and this preset diff --git a/ansible/roles/dev_user/defaults/main.yml b/ansible/roles/dev_user/defaults/main.yml index b2ded48..438965f 100644 --- a/ansible/roles/dev_user/defaults/main.yml +++ b/ansible/roles/dev_user/defaults/main.yml @@ -13,17 +13,21 @@ dev_user_groups: [] dev_user_sudo_nopasswd: false # ubuntu logs in on brew's zsh rather than /usr/bin/zsh, so the same choice here keeps the -# two accounts on one shell. It does mean login depends on dev_user_brew_prefix being readable. +# two accounts on one shell. It does mean login depends on dev_user_brew_prefix being readable +# -- the role installs the prefix and this one formula before it creates the account, so the +# default holds on a stock image rather than having to be downgraded per host. dev_user_shell: "{{ dev_user_brew_prefix }}/bin/zsh" # Point this at a larger volume when the root one is small. An instance store is wiped on stop/start, # so a home on one is disposable by construction — keys, tokens and history included. # -# The requirement below is why it is on: `nofail` turns a volume that did not come up into an -# ordinary directory, and the home lands on the root filesystem with nothing to say so. Set it false -# where the root volume is genuinely the intended home. +# The check below is off by default because the default home is /home/ on the root volume, +# and demanding a separate filesystem for it failed every ordinary box before it had done anything. +# Turn it on with a dev_user_home you meant to put elsewhere, and it earns its keep: `nofail` turns +# a volume that did not come up into an ordinary directory, so without the check the home lands on +# the root filesystem with nothing at all to say so. dev_user_home: "/home/{{ dev_user_name }}" -dev_user_home_require_separate_fs: true +dev_user_home_require_separate_fs: false # HTTPS, not SSH: this repository is public, and SSH authenticates whether or not the other end is, # so an SSH URL made provisioning wait on somebody registering a freshly generated key. Override # with an SSH URL for a private fork; the host-key pinning below covers it. @@ -41,6 +45,14 @@ dev_user_authorized_keys: [] dev_user_brew_prefix: /home/linuxbrew/.linuxbrew dev_user_brew_group: brew +# Install Homebrew when the prefix is not there. On by default: the login shell and every tool +# the Brewfile carries live inside the prefix, so a role that only ever shared an existing one +# produced a working account with none of them on a machine that had never had brew -- which is +# every fresh cloud image. Set it false to go back to sharing only what is already installed; +# the brew tasks then skip as they did before, and dev_user_shell has to name a shell that +# exists outside the prefix. +dev_user_brew_install: true + # Homebrew is 3.6G and officially single-owner. Rather than pay that twice: # group -- a shared group owns the prefix and both accounts may install. Convenient, # and the cost is occasional permission repair after a brew upgrade. @@ -76,19 +88,21 @@ dev_user_git_host_keys: [] # made traversable, and so no other local account can pre-place the path. dev_user_brew_stage_dir: /var/lib/dev-user-role -# Off by default: provisioning an account and populating a machine are different errands, and -# forty-odd formulae is minutes of side effect. Ask for it: `-e dev_user_brewfile=true`. -dev_user_brewfile: false +# On by default. Provisioning an account and populating a machine were treated as different +# errands, and the result was an account that had gh in its PATH via a wrapper, a gh.account +# convention in its gitconfig and no gh -- the tools this repo configures are the point of it. +# Forty-odd formulae is still minutes of side effect, so `-e dev_user_brewfile=false` for a run +# that should only make the account. +dev_user_brewfile: true -# Claude Code, installed into the account's own home by Anthropic's native installer. Off for the -# reason the Brewfile is. Set the -# channel to "stable" for the roughly week-old release, or to an exact version to pin one; empty -# means the default channel. A native install updates itself in the background, which is right for -# something used interactively and wrong for anything that has to be reproducible -- pin it if this -# account is ever something a build depends on. +# Claude Code, installed into the account's own home by Anthropic's native installer. On for the +# reason the Brewfile is. Set the channel to "stable" for the roughly week-old release, or to an +# exact version to pin one; empty means the default channel. A native install updates itself in +# the background, which is right for something used interactively and wrong for anything that has +# to be reproducible -- pin it if this account is ever something a build depends on. # # apt is the alternative: Anthropic publish a signed repository, which trades the self-update for a # GPG-verified package and a system-wide install. The native installer is what this uses because it # keeps the tool inside the home, like everything else this role puts there. -dev_user_claude_code: false +dev_user_claude_code: true dev_user_claude_code_channel: "" diff --git a/ansible/roles/dev_user/tasks/main.yml b/ansible/roles/dev_user/tasks/main.yml index 5c33030..f02a268 100644 --- a/ansible/roles/dev_user/tasks/main.yml +++ b/ansible/roles/dev_user/tasks/main.yml @@ -10,11 +10,83 @@ state: present become: true -# Homebrew first: the account's login shell lives inside the prefix, so it has to be -# reachable before the user is created or the first login has no shell to land in. -# The role does not install Homebrew; it only shares an existing prefix. Every brew task is -# gated on the prefix actually being there, including the ungated remediation below -- a bare -# find on a missing path exits 1, which would have killed the play on any host without it. +# Homebrew first: the account's login shell lives inside the prefix, and so does every tool +# the Brewfile installs, so the prefix has to be there before the user is created or the first +# login has no shell to land in. +# +# The role installs it when it is missing. It used to only share a prefix somebody else had +# already put there, and on a stock cloud image -- which has neither Homebrew nor zsh -- that +# meant every brew task below skipped, the login shell had to be downgraded to /bin/bash, and +# the account came up without gh, zellij or anything else this repo is about. Nothing said so: +# a skipped task is a green tick. Every brew task is still gated on the prefix existing, for +# the host that sets dev_user_brew_install false -- including the ungated remediation below, +# where a bare find on a missing path exits 1 and would kill the play. +- name: Is Homebrew already here? + ansible.builtin.stat: + path: "{{ dev_user_brew_prefix }}" + register: dev_user_brew_prefix_before + +# Asked of the target rather than read out of ansible_user_id, which is gathered under the +# play's own become and therefore says root on every host. The answer decides who ends up +# owning the prefix, so getting it from the wrong place is not a cosmetic error. +# check_mode: false so it runs during --check too. A skipped command still registers, as an +# object with no stdout, and the guard below reads that stdout inside a `when` -- so without +# this a dry run dies on an undefined attribute instead of reporting what it would do. +- name: Which account is this play connected as? + ansible.builtin.command: id -un + become: false + changed_when: false + check_mode: false + register: dev_user_connection_account + +# Homebrew refuses to run as uid 0 and escalates on its own where it needs to, so it wants an +# unprivileged account that can sudo -- which is what this playbook already needs to become +# root at all. Stated here because the installer's own refusal talks about interactive shells +# and never mentions the connection, which is the thing to change. +- name: Refuse to install Homebrew over a root connection + ansible.builtin.fail: + msg: >- + Homebrew will not install as root, and this play is connected as + {{ dev_user_connection_account.stdout | trim }}. Connect as an unprivileged account with + passwordless sudo, put Homebrew in {{ dev_user_brew_prefix }} by hand, or set + dev_user_brew_install to false and accept an account without the Brewfile. + when: + - dev_user_brew_install | bool + - not (dev_user_brew_prefix_before.stat.isdir | default(false)) + - (dev_user_connection_account.stdout | trim) == 'root' + +# The installer's own prerequisites, installed before it rather than after it complains. A +# cloud image carries curl and git but not reliably file, procps or a compiler, and the +# installer reports that only once it has already cloned itself into place. +- name: Packages the Homebrew installer needs + ansible.builtin.package: + name: [build-essential, curl, file, git, procps] + state: present + become: true + when: + - dev_user_brew_install | bool + - not (dev_user_brew_prefix_before.stat.isdir | default(false)) + - ansible_facts['system'] == 'Linux' + +# become: false, against the play's root default. The installer refuses uid 0, and the account +# it does run as is the one that ends up owning the prefix -- which is the account every brew +# task below becomes, and the one that must already hold passwordless sudo, because the +# installer calls sudo itself rather than inheriting this play's escalation. +- name: Install Homebrew + ansible.builtin.shell: | + set -o pipefail + NONINTERACTIVE=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + args: + executable: /bin/bash + creates: "{{ dev_user_brew_prefix }}/bin/brew" + become: false + when: + - dev_user_brew_install | bool + - not (dev_user_brew_prefix_before.stat.isdir | default(false)) + +# Re-stat, and unconditionally: a skipped task overwrites its register with the skip, so a +# `when` here would erase the very fact it exists to refresh. Everything downstream reads this +# one, so it has to describe the prefix as it is after the install, not before it. - name: Check whether the Homebrew prefix exists ansible.builtin.stat: path: "{{ dev_user_brew_prefix }}" @@ -26,6 +98,17 @@ {{ dev_user_brew_owner if dev_user_brew_owner | length > 0 else (dev_user_brew_prefix_stat.stat.pw_name | default('root')) }} +# Stated rather than assumed, the same way Claude Code is further down: an installer exiting 0 +# is not the same as a brew that runs, and on a first install this is also the first time the +# play becomes the account that owns the prefix. +- name: Homebrew answers + ansible.builtin.command: "{{ dev_user_brew_prefix }}/bin/brew --version" + become: true + become_user: "{{ dev_user_brew_runner }}" + register: dev_user_brew_version + changed_when: false + when: dev_user_brew_prefix_stat.stat.isdir | default(false) + - name: Ensure the shared brew group exists ansible.builtin.group: name: "{{ dev_user_brew_group }}" @@ -122,9 +205,25 @@ - dev_user_home_require_separate_fs | bool - (dev_user_home_fs.stdout_lines[0] | trim) == (dev_user_home_fs.stdout_lines[1] | trim) +# The default login shell is brew's zsh, and the Brewfile that would install it is staged out +# of the dotfiles clone -- which lives in a home that does not exist yet. So the shell is +# installed here, on its own, rather than waiting for the bundle: it is the one formula the +# account cannot be created without. Named from dev_user_shell rather than hardcoded, so a +# host that points the shell at some other formula in the prefix gets that one installed. +- name: Install the login shell, when it lives in the Homebrew prefix + ansible.builtin.command: + cmd: "{{ dev_user_brew_prefix }}/bin/brew install {{ dev_user_shell | basename }}" + creates: "{{ dev_user_shell }}" + become: true + become_user: "{{ dev_user_brew_runner }}" + when: + - dev_user_brew_prefix_stat.stat.isdir | default(false) + - dev_user_shell.startswith(dev_user_brew_prefix ~ '/') + # useradd does not check that the shell exists, so a missing one produces an account nobody -# can log into while every task still reports ok. The default shell lives inside the Homebrew -# prefix, which this role does not install. +# can log into while every task still reports ok. The check stays even now that the task above +# installs it: dev_user_brew_install can be off, the prefix can be absent, and a host is free +# to name a shell that lives nowhere near Homebrew. - name: Check that the login shell exists on the target ansible.builtin.stat: path: "{{ dev_user_shell }}" @@ -135,6 +234,8 @@ msg: >- dev_user_shell is {{ dev_user_shell }}, which does not exist on this host. Install it first, or set dev_user_shell to a shell that is present (/bin/bash on a stock Ubuntu). + A shell inside {{ dev_user_brew_prefix }} means Homebrew is not there either -- leave + dev_user_brew_install true and the role will put both in place. when: not (dev_user_shell_stat.stat.exists | default(false)) # A group is granted where it exists. The user module fails on a name it cannot find, which diff --git a/ansible/site.yml b/ansible/site.yml index b25b0d2..c584e8e 100644 --- a/ansible/site.yml +++ b/ansible/site.yml @@ -1,7 +1,10 @@ --- # Provisions a namespaced dev account on a box that belongs to other things too. Run it -# from an account with sudo; it touches nothing outside the new home except the Homebrew -# group, and only then when dev_user_brew_share_mode is 'group'. +# from an account with sudo -- passwordless, because the Homebrew installer calls sudo itself +# rather than inheriting this play's escalation. Outside the new home it touches the Homebrew +# prefix (installing it when absent, as the invoking account, which then owns it), the +# installer's apt prerequisites, and the Homebrew group when dev_user_brew_share_mode is +# 'group'. # # ansible-playbook -i inventory.ini site.yml --check # dry run first # ansible-playbook -i inventory.ini site.yml