Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ jobs:
# referenced three times and defined none. Every one of those passed --syntax-check and
# ansible-lint. A runner is a disposable machine with sudo, which is what this role wants.
#
# One blind spot worth naming, because it hid a real defect: the runner ships apt's jq on
# the default PATH, so this job cannot catch install.sh failing to find the jq that comes
# from the Brewfile. On a stock cloud image jq exists only inside the Homebrew prefix,
# which no login shell has put on PATH by the time the role runs the installer. That was
# found by running the role in a bare ubuntu:24.04 container, not here.
#
# The clone is the checkout itself, not the published repo -- otherwise CI would provision
# from main and pass on a pull request whose own install.sh is broken.
#
Expand All @@ -141,12 +147,17 @@ jobs:
cat > /tmp/ci-vars.yml <<YML
dev_user_name: citest
dev_user_comment: "CI test account"
dev_user_shell: /bin/bash
dev_user_dotfiles_repo: file:///tmp/dotfiles-src
dev_user_dotfiles_version: $(git -C "$GITHUB_WORKSPACE" rev-parse HEAD)
# The runner ships Homebrew, so the brew gate passes and the role would install the whole
# Brewfile -- forty-odd formulae, minutes per run, and not what this job is checking. The
# shell job already parses the Brewfile; nothing here exercises installing it.
# The login shell is left at the role's default so this job exercises installing it.
# It used to be pinned to /bin/bash because the default lived in the Homebrew prefix
# and the role could not be trusted to have put it there; it is now the distribution's
# zsh, installed from the same package task as the rest of the prerequisites.
#
# The runner ships Homebrew already, which is exactly the case worth testing: the role
# has to take ownership of a prefix another account installed. Installing the whole
# Brewfile is still forty-odd formulae and minutes per run, so that stays off and the
# step below installs a single formula to prove the takeover worked.
dev_user_brewfile: false
# The home is the runner's root volume, which is the only one it has.
dev_user_home_require_separate_fs: false
Expand All @@ -161,5 +172,22 @@ jobs:
sudo test -L /home/citest/.zshrc || { echo ".zshrc is not a link"; exit 1; }
sudo test -f /home/citest/.claude/settings.json || { echo "settings.json missing"; exit 1; }
sudo test ! -L /home/citest/.claude/settings.json || { echo "settings.json should be generated, not linked"; exit 1; }
# HOME_MODE on Ubuntu, which the role neither sets nor works around.
[ "$(stat -c %a /home/citest)" = "750" ] || { echo "home is $(stat -c %a /home/citest), not 750"; exit 1; }
[ "$(getent passwd citest | cut -d: -f7)" = "/usr/bin/zsh" ] || { echo "login shell is not zsh"; exit 1; }
sudo -u citest -H /home/citest/.local/bin/claude --version

# The point of the whole change. The runner installed Homebrew as somebody else, so this
# passes only if the role took the prefix over -- and installing is the operation that
# ownership actually gates. Under the previous group/readonly sharing this was the thing
# that did not work: the account could run the binaries and could not add to them.
- name: The account owns Homebrew and can install with it
# From /, not the workspace: a step runs in $GITHUB_WORKSPACE, which lives under the
# runner's own 0750 home, and brew refuses to start when it cannot stat its working
# directory -- which citest cannot, because it is not the runner.
run: |
owner="$(stat -c %U /home/linuxbrew/.linuxbrew)"
[ "$owner" = "citest" ] || { echo "prefix owned by $owner, not citest"; exit 1; }
cd /
sudo -u citest -H /home/linuxbrew/.linuxbrew/bin/brew install hello
sudo -u citest -H /home/linuxbrew/.linuxbrew/bin/hello --version | head -1
13 changes: 4 additions & 9 deletions ansible/hosts/lily.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@
#
# 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.
# Everything else this box needs is the role's own default -- Homebrew, the Brewfile, the shell,
# Claude Code, and passwordless sudo, which is no longer a flag because an account created with a
# locked password has no other kind of sudo available to it.

dev_user_authorized_keys:
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHEHSv9U28JSaick1k4nTgA5Fb4nDUM1RYqv/uIlxBZ+ lily.dayton@mac"
Expand All @@ -19,8 +17,5 @@ dev_user_authorized_keys:
# 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
# names it: these boxes are provisioned to be worked on, and the docker group is root-equivalent
# anyway, so withholding sudo beside it buys nothing.
dev_user_sudo_nopasswd: true
# The docker group is root-equivalent, so it is named per host rather than assumed.
dev_user_groups: [docker]
73 changes: 36 additions & 37 deletions ansible/roles/dev_user/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
---
# The account this role provisions. Everything carrying identity -- ssh keys, gh tokens,
# ~/.claude, shell and atuin history -- lands inside its home and is shared with nobody.
# The account this role provisions: an ordinary user of the box, not a tenant on it. Everything
# carrying identity -- ssh keys, gh tokens, ~/.claude, shell and atuin history -- lands inside
# its home and is shared with nobody, but the box itself is hers: she owns the Homebrew prefix,
# installs her own packages, and reaches root without re-authenticating.
dev_user_name: lily
dev_user_comment: "Lily Dayton"
# Sudo on a box that belongs to other things is a grant worth naming per host.
# Supplementary groups, granted where the box has them. A name the box does not know is
# reported and skipped rather than failing the run.
dev_user_groups: []

# Passwordless sudo, written as a drop-in rather than group membership: the grant is then one
# file to read and one file to delete, and visudo checks it before it lands. Off for the same
# reason the groups above are empty -- an account that reaches root without re-authenticating is
# a decision per host. Setting it false removes a grant an earlier run left behind.
dev_user_sudo_nopasswd: false
# Passwordless sudo is not optional and has no flag. The account is created with a locked
# password, so password sudo could never succeed -- the choice was never "which kind of sudo",
# it was "sudo or none". The Homebrew installer also probes `sudo -n` before it will touch
# /home, so the grant has to exist before the prefix does. It is written as a drop-in rather
# than group membership: one file to read, one file to delete, and visudo checks it first.
dev_user_sudoers_file: "/etc/sudoers.d/60-{{ dev_user_name }}"

# 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
# -- 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"
# The distribution's zsh, not Homebrew's. They are the same release -- 5.9 -- so the only thing
# the Homebrew one ever bought was matching the cloud image's own account, which stopped being
# a consideration when this account became the one you log into. Naming a shell outside the
# prefix is what lets the account be created before Homebrew exists, which in turn is what lets
# the account install Homebrew itself. The Brewfile still installs zsh; it just isn't this.
dev_user_shell: /usr/bin/zsh
dev_user_shell_package: 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.
Expand All @@ -43,29 +49,26 @@ dev_user_dotfiles_dest: "{{ dev_user_home }}/source/dotfiles"
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.
# Install Homebrew when the prefix is not there. On by default: every tool the Brewfile carries
# lives inside the prefix, so a role that only ever shared an existing one produced an account
# with none of them on a machine that had never had brew -- which is every fresh cloud image.
# Set it false to provision an account with no Homebrew at all; the brew tasks then skip.
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.
# readonly -- the prefix is left alone. Binaries run fine for everyone; installs happen
# as whoever owns it (`sudo -u ubuntu brew install ...`). Nothing to break.
dev_user_brew_share_mode: readonly

# Whoever owns the prefix runs brew. Empty means ask the filesystem, which is the only answer
# that is true everywhere: the prefix's owner IS the account that installed it. A literal here was
# the cloud image's account name, and it was wrong on the first machine that was not that image --
# the run then tried to become a user who did not exist and failed on a temp-file permission it
# could not set.
dev_user_brew_owner: ""
# Homebrew is officially single-owner, and this role takes it at its word: the account owns the
# prefix and runs brew as itself. A prefix somebody else installed is chowned over on the first
# run -- seconds, and it copies nothing, because ownership is not disk.
#
# The cost is real and worth stating: whoever owned it before can still run every binary in the
# prefix, but can no longer `brew install`. On a box where another account needs to install its
# own packages, this is the wrong role.
#
# The alternative, sharing the prefix through a group, is what this replaced. It needed setgid on
# directories, group-write across the tree, and a repair pass for the combination of the two --
# and since group-write reaches the login shell of an account holding sudo, membership was
# equivalent to root anyway. Single ownership is both simpler and the supported configuration.
dev_user_brew_take_ownership: true

# Private overlay, off by default -- this repo stays portable and employer-agnostic. Set
# dev_user_overlay_repo in an untracked vars file (see private.yml.example) to enable.
Expand All @@ -84,10 +87,6 @@ dev_user_git_host: github.com
dev_user_git_host_keys_url: https://api.github.com/meta
dev_user_git_host_keys: []

# Root-owned, so the prefix owner can read the staged Brewfile without the new home being
# made traversable, and so no other local account can pre-place the path.
dev_user_brew_stage_dir: /var/lib/dev-user-role

# 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.
Expand Down
4 changes: 2 additions & 2 deletions ansible/roles/dev_user/tasks/links.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@

# Skipping the home directory itself is the point of the `when`. The dirname of a dest like
# .zshrc IS the home, and the file module enforces mode on a directory that already exists --
# so without this the home goes 0755 on the first run. That would quietly undo useradd's 0750
# and, with it, the reason the Brewfile is staged into a root-owned directory further down.
# so without this the home goes 0755 on the first run, quietly undoing the 0750 that HOME_MODE
# gives every user on this distribution.
- name: "Ensure the link destinations have parents ({{ link_root | basename }})"
ansible.builtin.file:
path: "{{ (dev_user_home ~ '/' ~ item.dest) | dirname }}"
Expand Down
Loading
Loading