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