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
14 changes: 14 additions & 0 deletions ansible/molecule/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ it.
molecule converge -s existing-host -- --tags soe
molecule verify -s existing-host

**When the connection user is not the desktop user**, name the desktop one --
a fleet machine is reached as a service account whose home holds none of the
artefacts under test:

export MOLECULE_TARGET_USER=ubuntu # who we ssh as
export MOLECULE_TARGET_DESKTOP_USER=hyperi # whose machine it is
molecule converge -s existing-host -- --tags soe -e hyperi_target_user=hyperi
molecule verify -s existing-host

Without it every user-scoped check passes against the service account's empty
home while the real user keeps the artefact -- a green run over a host that was
never fixed. `converge` takes it as `-e hyperi_target_user`; `verify` takes no
extra arguments, so it reads the environment.

`--tags soe` (or `--tags removals`) is not optional for a remediation run: the
tombstones gate on `ansible_run_tags`, so a plain converge installs the new
tools and removes nothing.
Expand Down
62 changes: 59 additions & 3 deletions ansible/molecule/existing-host/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,42 @@
hosts: all
gather_facts: true

vars:
# The DESKTOP user, which on a fleet machine is not the user ansible
# connected as. Checking the service account's empty home passes every
# user-scoped assert below while the real user still carries the artefact,
# so this is supplied the same way the inventory takes its host:
# MOLECULE_TARGET_DESKTOP_USER=hyperi molecule verify -s existing-host
verify_user: >-
{{ lookup('env', 'MOLECULE_TARGET_DESKTOP_USER')
| default(hyperi_target_user | default(ansible_user_id, true), true) }}

tasks:
# ------------------------------------------------------------------
# SHADOWING — a stale user-level binary ahead of the system one on PATH
# ------------------------------------------------------------------
- name: Resolve the desktop user's home
ansible.builtin.getent:
database: passwd
key: "{{ verify_user }}"
become: true

- name: Set the home to check
ansible.builtin.set_fact:
verify_home: "{{ ansible_facts.getent_passwd[verify_user][4] }}"

- name: Report which user is being verified
ansible.builtin.debug:
msg: "Verifying user-scoped state for {{ verify_user }} ({{ verify_home }})"

- name: Stat the retired user-level uv locations
ansible.builtin.stat:
path: "{{ item }}"
loop:
- "{{ ansible_env.HOME }}/.cargo/bin/uv"
- "{{ ansible_env.HOME }}/.cargo/bin/ruff"
- "{{ verify_home }}/.cargo/bin/uv"
- "{{ verify_home }}/.cargo/bin/ruff"
register: verify_stale_uv
become: true

- name: Assert no stale user-level uv shadows the system install
ansible.builtin.assert:
Expand Down Expand Up @@ -83,6 +108,37 @@
loop_control:
label: "{{ item.item }}"

# A docker context is a per-user file, not package state, so it survives
# the uninstall. Left selected, it points at a dead socket and every docker
# command fails.
- name: Read the docker contexts
ansible.builtin.command: docker context ls --quiet
register: verify_docker_ctx
changed_when: false
failed_when: false
become: true
become_user: "{{ verify_user }}"

- name: Assert the retired Docker Desktop context is gone
ansible.builtin.assert:
that:
- "'desktop-linux' not in (verify_docker_ctx.stdout_lines | default([]))"
fail_msg: >-
The desktop-linux context is still registered. It points at Docker
Desktop's socket, which no longer exists — and if it is also the
selected context, every docker command on this host fails.
success_msg: "no retired Docker Desktop context"
when: verify_docker_ctx.rc == 0

- name: Assert the selected docker context is not the retired one
ansible.builtin.command: docker context show
register: verify_docker_ctx_current
changed_when: false
failed_when: verify_docker_ctx_current.stdout | trim == 'desktop-linux'
become: true
become_user: "{{ verify_user }}"
when: verify_docker_ctx.rc == 0

# ------------------------------------------------------------------
# RETIRED PACKAGES — dropped by decision, must not linger
# ------------------------------------------------------------------
Expand Down Expand Up @@ -166,7 +222,7 @@
# ------------------------------------------------------------------
- name: Read ~/.zprofile if present
ansible.builtin.slurp:
src: "{{ ansible_env.HOME }}/.zprofile"
src: "{{ verify_home }}/.zprofile"
register: verify_zprofile
failed_when: false

Expand Down
10 changes: 9 additions & 1 deletion ansible/playbooks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,17 @@
(ansible_facts['distribution'] == 'Ubuntu' and ansible_facts['distribution_version'] is version('24.04', '<'))
tags: ['always']

# Whose machine this is. Normally the user running the install, but a fleet
# machine is reached as a service account (hyperi-infra connects as
# `ubuntu`) whose home is not the desktop, so `-e hyperi_target_user=<name>`
# names the real one. Everything user-scoped keys off this: shell config,
# uv, Claude Code, dconf, and the tombstones.
- name: Detect actual user (for sudo operations)
ansible.builtin.set_fact:
actual_user: "{{ ansible_facts['env'].SUDO_USER | default(ansible_facts['user_id'], true) }}"
actual_user: >-
{{ hyperi_target_user
| default(ansible_facts['env'].SUDO_USER
| default(ansible_facts['user_id'], true), true) }}
tags: ['always']

- name: Set actual user home directory (Linux)
Expand Down
11 changes: 10 additions & 1 deletion ansible/roles/astral/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@
dest: "/tmp/uv-{{ uv_arch }}.tar.gz"
mode: '0644'

# Check mode only previews the directory and the download above, so on a
# host that has never had uv this fails the whole run instead of
# previewing it.
- name: Extract uv
ansible.builtin.unarchive:
src: "/tmp/uv-{{ uv_arch }}.tar.gz"
Expand All @@ -83,6 +86,7 @@
group: "{{ actual_user }}"
extra_opts:
- --strip-components=1
when: not ansible_check_mode

- name: Remove uv tarball
ansible.builtin.file:
Expand Down Expand Up @@ -115,12 +119,17 @@

# Ubuntu: no apt repo; install as user-owned uv tools (hyperi-update refreshes
# via `uv tool upgrade`). uv is installed above, so it is on PATH here.
#
# Runs as the owner of that home, not as whoever connected: `become: false`
# means the connection user, and on a fleet machine that is a service account
# with no write access to the desktop user's home.
- name: Install ruff and ty (Ubuntu - uv tools, hyperi-update tracks these)
ansible.builtin.command:
cmd: "uv tool install {{ item }}"
environment:
PATH: "{{ user_home }}/.cargo/bin:{{ user_home }}/.local/bin:{{ ansible_facts['env'].PATH }}"
become: false
become: true
become_user: "{{ actual_user }}"
loop:
- ruff
- ty
Expand Down
3 changes: 2 additions & 1 deletion ansible/roles/developer-python/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
cmd: uv tool install mypy
environment:
PATH: "{{ user_home }}/.cargo/bin:{{ user_home }}/.local/bin:{{ ansible_facts['env'].PATH }}"
become: false
become: "{{ ansible_facts['distribution'] != 'MacOSX' }}"
become_user: "{{ actual_user }}"
register: devpy_mypy_install
changed_when: "'already installed' not in (devpy_mypy_install.stderr | default(''))"
failed_when: false
Expand Down
35 changes: 33 additions & 2 deletions ansible/roles/developer/files/update/hyperi-update-linux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -140,22 +140,53 @@ if [[ "$ASSUME_YES" -eq 0 ]]; then
fi

# --- sudo: ask once, keep alive -------------------------------------------
# Probe with a real command, not `sudo -v`. Ubuntu 25.10+ replaced GNU sudo with
# sudo-rs, whose `-v` demands interactive authentication even where NOPASSWD
# grants the commands themselves — so `sudo -v` aborts this script on every
# passwordless box and under every unattended caller, which is exactly what
# --yes exists for.
#
# The keepalive only matters when a password was actually entered: NOPASSWD
# leaves no timestamp to refresh.
section "Authenticating (sudo)"
if sudo -v; then
if sudo -n true 2>/dev/null; then
ok "sudo authenticated (passwordless)"
elif [[ -t 0 ]] && sudo -v; then
ok "sudo authenticated"
# refresh the sudo timestamp in the background until the script exits
( while true; do sudo -n true 2>/dev/null; sleep 50; kill -0 "$$" 2>/dev/null || exit; done ) &
SUDO_KEEPALIVE_PID=$!
trap '[[ -n "${SUDO_KEEPALIVE_PID:-}" ]] && kill "$SUDO_KEEPALIVE_PID" 2>/dev/null' EXIT
else
printf '%s \xe2\x9c\x97 sudo authentication failed — aborting%s\n' "$RED" "$RESET"
printf '%s No passwordless sudo and no terminal to prompt on.%s\n' "$RED" "$RESET"
exit 1
fi

# --- System packages -------------------------------------------------------
# unattended-upgrades and the apt-daily timers take the dpkg lock on their own
# schedule. Without this wait the upgrade exits 100 and the run reports success
# for everything else, so the box looks updated and is not.
wait_for_apt_lock() {
local waited=0
while sudo fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 ||
sudo fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do
if [[ "$waited" -eq 0 ]]; then
printf ' waiting for another apt process to finish...\n'
fi
if [[ "$waited" -ge 300 ]]; then
fail "apt lock (held for 5 minutes)"
return 1
fi
sleep 5
waited=$((waited + 5))
done
return 0
}

case "$PKG_MGR" in
apt)
section "APT — system packages"
wait_for_apt_lock
run "apt-get update" sudo apt-get update
run "apt-get full-upgrade" sudo apt-get -y full-upgrade
run "apt-get autoremove" sudo apt-get -y autoremove
Expand Down
12 changes: 8 additions & 4 deletions ansible/roles/developer/tasks/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@
- name: Install the previous Node major via fnm
ansible.builtin.command:
cmd: /usr/local/bin/fnm install {{ node_major_previous }}
become: false
become: true
become_user: "{{ actual_user }}"
environment:
FNM_DIR: "{{ user_home }}/.local/share/fnm"
register: developer_fnm_install
Expand All @@ -183,7 +184,8 @@
- name: Point the fnm default at the system Node
ansible.builtin.command:
cmd: /usr/local/bin/fnm default system
become: false
become: true
become_user: "{{ actual_user }}"
environment:
FNM_DIR: "{{ user_home }}/.local/share/fnm"
register: developer_fnm_default
Expand Down Expand Up @@ -288,10 +290,12 @@
become: false
when: ansible_facts['distribution'] == 'MacOSX'

# become_user is ignored while become is false, so this has to escalate to
# reach the desktop user's ~/.npmrc rather than the connecting account's.
- name: Configure the npm prefix (Linux)
ansible.builtin.command:
cmd: npm config set prefix "{{ user_home }}/.npm-global"
become: false
become: true
become_user: "{{ actual_user }}"
changed_when: false
when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu']
Expand Down Expand Up @@ -431,7 +435,7 @@
state: present
environment:
NPM_CONFIG_PREFIX: "{{ user_home }}/.npm-global"
become: false
become: true
become_user: "{{ actual_user }}"
when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu']

Expand Down
54 changes: 53 additions & 1 deletion ansible/roles/developer/tasks/removals.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,20 @@
# user-owned in ~/.local/bin there -- ~/.cargo/bin is a retired location on all
# three, and it sits AHEAD of ~/.local/bin on PATH, so a copy left there
# shadows the real install.
#
# Runs as the owner of that home, not as whoever connected. Under `become:
# false` on a machine where they differ, the file module cannot read the path
# and reports "Insufficient permissions ... Treating as absent" -- a green
# tombstone over a binary that is still there and still shadowing.
- name: Remove the retired user-level uv/uvx (superseded by the current install)
ansible.builtin.file:
path: "{{ user_home }}/.cargo/bin/{{ item }}"
state: absent
loop:
- uv
- uvx
become: false
become: "{{ ansible_facts['distribution'] != 'MacOSX' }}"
become_user: "{{ actual_user }}"

# ============================================================================
# CANNOT CO-EXIST — the old thing actively conflicts with its replacement.
Expand Down Expand Up @@ -137,6 +143,52 @@
when: ansible_facts['distribution'] == 'MacOSX'
failed_when: false

# The package uninstall above does NOT take the docker context with it: context
# state is a per-user file under ~/.docker/contexts, owned by the user rather
# than the package. What is left points at a socket that no longer exists, and
# where `desktop-linux` is also the SELECTED context every docker command fails
# with "Cannot connect to the Docker daemon" until it is switched back.
#
# Context subcommands do not talk to the daemon, so these still work on a host
# whose selected context is already dead.
- name: Read the current docker context
ansible.builtin.command:
cmd: docker context show
become: "{{ ansible_facts['distribution'] != 'MacOSX' }}"
become_user: "{{ actual_user }}"
register: developer_docker_ctx_current
changed_when: false
failed_when: false

- name: List the docker contexts
ansible.builtin.command:
cmd: docker context ls --quiet
become: "{{ ansible_facts['distribution'] != 'MacOSX' }}"
become_user: "{{ actual_user }}"
register: developer_docker_ctx_list
changed_when: false
failed_when: false

# `docker context rm` refuses to remove the context in use, so this has to come
# first.
- name: Switch off the retired Docker Desktop context
ansible.builtin.command:
cmd: docker context use default
become: "{{ ansible_facts['distribution'] != 'MacOSX' }}"
become_user: "{{ actual_user }}"
changed_when: true
failed_when: false
when: developer_docker_ctx_current.stdout | default('') | trim == 'desktop-linux'

- name: Remove the retired Docker Desktop context
ansible.builtin.command:
cmd: docker context rm desktop-linux
become: "{{ ansible_facts['distribution'] != 'MacOSX' }}"
become_user: "{{ actual_user }}"
changed_when: true
failed_when: false
when: "'desktop-linux' in (developer_docker_ctx_list.stdout_lines | default([]))"

# OnlyOffice - retired; LibreOffice is the org office suite now. Remove the
# native package and the Flatpak fallback from hosts that carried it.
- name: Remove OnlyOffice desktop editors (Fedora)
Expand Down
2 changes: 1 addition & 1 deletion ansible/roles/developer/tasks/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
PATH: "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:{{ user_home }}/.cargo/bin:{{ ansible_facts['env'].PATH }}"
changed_when: false
failed_when: verify_git_lfs.rc != 0
become: false
become: "{{ ansible_facts['distribution'] != 'MacOSX' }}"
become_user: "{{ actual_user }}"

- name: Verify yq
Expand Down
Loading
Loading