From 1f87d84b1f6dcc1a32d242875f59b20730b3f42c Mon Sep 17 00:00:00 2001 From: Derek Date: Tue, 6 Oct 2026 15:34:52 +1100 Subject: [PATCH 1/5] fix: clear hand-installed copies of managed tools A tool installed by hand next to the one the roles manage leaves two copies, and the one in ~/.local/bin, ~/go/bin or a cargo home wins on PATH while nothing updates it. Each copy now goes only where the managed copy exists and is a different file (device and inode after following links), so the only working copy is never the one removed. The removals tag clears user-level copies of the tools the roles install system-wide, and purges a .deb or .rpm that duplicates a /usr/local/bin tool when no repository offers it and a simulated removal takes nothing else. developer-go removes the distro Go once /usr/local/go is in, kept with a message when another package needs it. developer-rust deregisters cargo-home copies of sccache, sd and fnm with cargo uninstall --root, so cargo install-update cannot bring them back, and removes uv and uvx left by the old installer. cargo-tarpaulin is retired from every cargo home, including one cargo has no record of, which used to fail the uninstall. Where CARGO_HOME is relocated, binaries the effective home also holds are removed from the old ~/.cargo/bin, and its caches and anything installed only there stay. Relocation is now decided by comparing the directories, not their paths. A ~/.cargo symlinked to the relocated home read as stale, so the live config.toml was renamed to config.toml.superseded on every run. typos and alint install into the effective CARGO_HOME, and the soe PATH drop-in and the hyperi-update scripts use ${CARGO_HOME:-$HOME/.cargo}/bin. The Linux update script also puts /usr/local/go/bin on PATH, which a GUI launch otherwise lacks once the distro Go is gone. --- ansible/molecule/remediation/prepare.yml | 135 +++++++++++ ansible/molecule/remediation/verify.yml | 83 +++++++ ansible/roles/contributor/tasks/hyperi_ci.yml | 18 +- ansible/roles/contributor/tasks/typos.yml | 8 +- ansible/roles/contributor/tasks/verify.yml | 4 +- ansible/roles/developer-go/tasks/main.yml | 10 + .../roles/developer-go/tasks/superseded.yml | 125 ++++++++++ ansible/roles/developer-rust/README.md | 20 +- ansible/roles/developer-rust/tasks/rust.yml | 56 +++-- ansible/roles/developer-rust/tasks/strays.yml | 174 ++++++++++++++ .../files/update/hyperi-update-linux.sh | 6 +- .../files/update/hyperi-update-macos.sh | 2 +- ansible/roles/developer/tasks/removals.yml | 217 ++++++++++++++++++ ansible/roles/soe/tasks/shell_config.yml | 2 +- docs/install-matrix.md | 2 + 15 files changed, 821 insertions(+), 41 deletions(-) create mode 100644 ansible/roles/developer-go/tasks/superseded.yml create mode 100644 ansible/roles/developer-rust/tasks/strays.yml diff --git a/ansible/molecule/remediation/prepare.yml b/ansible/molecule/remediation/prepare.yml index 97344d0..e21b3b6 100644 --- a/ansible/molecule/remediation/prepare.yml +++ b/ansible/molecule/remediation/prepare.yml @@ -370,6 +370,141 @@ mode: '0644' when: ansible_facts['distribution'] == 'Ubuntu' + # ------------------------------------------------------------------ + # HAND-INSTALLED DUPLICATES -- a second copy of a tool the roles install + # system-wide, left by a manual install. Each must go only where the + # managed copy is in place and is a different file. + # ------------------------------------------------------------------ + # Observed on the reference workstation 2026-10-06: ~/.local/bin/kind + # shadowing /usr/local/bin/kind. CONSTRUCTED: the same shadow from ~/go/bin. + - name: Plant the managed /usr/local/bin copies the user-level ones shadow + ansible.builtin.copy: + content: | + #!/bin/sh + echo "managed fixture: {{ item }}" + dest: "/usr/local/bin/{{ item }}" + owner: root + group: root + mode: '0755' + loop: + - kind + - dive + - argocd + - macbash + - git-scrub + + - name: Create the user-level bin directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: '0755' + loop: + - "{{ ansible_facts['env']['HOME'] }}/.local/bin" + - "{{ ansible_facts['env']['HOME'] }}/go/bin" + + - name: Plant user-level copies of managed tools + ansible.builtin.copy: + content: | + #!/bin/sh + echo "hand-installed fixture: {{ item | basename }}" + dest: "{{ ansible_facts['env']['HOME'] }}/{{ item }}" + mode: '0755' + loop: + - .local/bin/kind # shadows /usr/local/bin/kind -- must go + - go/bin/dive # shadows /usr/local/bin/dive -- must go + - .local/bin/kubeconform # no system copy here -- the only one, must stay + - .local/bin/tinygo-dev # not a tool the roles manage -- must stay + + # CONSTRUCTED: a user-level link to the managed binary is not a duplicate. + - name: Link a user-level argocd to the managed copy + ansible.builtin.file: + src: /usr/local/bin/argocd + dest: "{{ ansible_facts['env']['HOME'] }}/.local/bin/argocd" + state: link + + # Observed on the reference workstation 2026-10-06: the macbash .deb, from no + # repository, beside the role's /usr/local/bin/macbash. CONSTRUCTED: a + # git-scrub package another package depends on, which must stay. + - name: Install hand-built duplicate packages (Ubuntu) + when: ansible_facts['distribution'] == 'Ubuntu' + block: + - name: Create the fixture package trees + ansible.builtin.file: + path: "/root/dup-fixture/{{ item.0 }}/{{ item.1 }}" + state: directory + mode: '0755' + loop: "{{ ['macbash', 'git-scrub', 'git-scrub-dependant'] | product(['DEBIAN', 'usr/bin']) | list }}" + + - name: Write the fixture control files + ansible.builtin.copy: + content: | + Package: {{ item.name }} + Version: 1.0.0 + Architecture: all + Maintainer: hyperi-developer fixture + {% if item.depends %}Depends: {{ item.depends }} + {% endif %}Description: Stand-in for a hand-installed package + dest: "/root/dup-fixture/{{ item.name }}/DEBIAN/control" + mode: '0644' + loop: + - {name: macbash, depends: ''} + - {name: git-scrub, depends: ''} + - {name: git-scrub-dependant, depends: git-scrub} + + - name: Write the packaged binaries + ansible.builtin.copy: + content: | + #!/bin/sh + echo "hand-installed package fixture: {{ item }}" + dest: "/root/dup-fixture/{{ item }}/usr/bin/{{ item }}" + mode: '0755' + loop: + - macbash + - git-scrub + - git-scrub-dependant + + - name: Build the fixture packages + ansible.builtin.command: + cmd: "dpkg-deb --build --root-owner-group /root/dup-fixture/{{ item }} /root/dup-fixture/{{ item }}.deb" + creates: "/root/dup-fixture/{{ item }}.deb" + loop: + - macbash + - git-scrub + - git-scrub-dependant + + - name: Install the fixture packages + ansible.builtin.apt: + deb: "/root/dup-fixture/{{ item }}.deb" + loop: + - macbash + - git-scrub + - git-scrub-dependant + + # Observed on the reference workstation 2026-10-06: golang-go with no reverse + # dependencies beside the role's /usr/local/go. A script stands in for the + # managed toolchain. + - name: Install the distro Go (Ubuntu) + ansible.builtin.apt: + name: golang-go + state: present + when: ansible_facts['distribution'] == 'Ubuntu' + + - name: Create the stand-in managed Go tree + ansible.builtin.file: + path: /usr/local/go/bin + state: directory + mode: '0755' + + - name: Plant the stand-in managed Go + ansible.builtin.copy: + content: | + #!/bin/sh + echo "go version go0.0.0-fixture linux/amd64" + dest: /usr/local/go/bin/go + owner: root + group: root + mode: '0755' + # The unguarded ~/.bashrc PATH lines are deliberately NOT planted. The tasks # that remove them sit in the install path of the developer and # developer-rust roles, not under the `removals` tag this scenario diff --git a/ansible/molecule/remediation/verify.yml b/ansible/molecule/remediation/verify.yml index 726ae6d..0879571 100644 --- a/ansible/molecule/remediation/verify.yml +++ b/ansible/molecule/remediation/verify.yml @@ -264,6 +264,89 @@ success_msg: "DBeaver flatpak removed" when: ansible_facts['distribution'] == 'Ubuntu' + # ================================================================== + # HAND-INSTALLED DUPLICATES -- both directions. + # ================================================================== + - name: Stat the user-level copies + ansible.builtin.stat: + path: "{{ ansible_facts['env']['HOME'] }}/{{ item.path }}" + follow: false + loop: + - {path: .local/bin/kind, gone: true} + - {path: go/bin/dive, gone: true} + - {path: .local/bin/kubeconform, gone: false} + - {path: .local/bin/tinygo-dev, gone: false} + - {path: .local/bin/argocd, gone: false} + loop_control: + label: "{{ item.path }}" + register: verify_user_copies + + - name: Assert user-level duplicates went and everything else stayed + ansible.builtin.assert: + that: + - item.stat.exists != item.item.gone + fail_msg: >- + ~/{{ item.item.path }} {{ 'survived beside the managed copy it shadows' + if item.item.gone else 'was REMOVED -- it was the only copy, a link to + the managed one, or a tool the roles do not manage' }}. + success_msg: "~/{{ item.item.path }} {{ 'removed' if item.item.gone else 'left alone' }}" + loop: "{{ verify_user_copies.results }}" + loop_control: + label: "{{ item.item.path }}" + + # The managed copies themselves must never be what goes. + - name: Stat the managed copies behind the duplicates + ansible.builtin.stat: + path: "{{ item }}" + loop: + - /usr/local/bin/kind + - /usr/local/bin/dive + - /usr/local/bin/argocd + - /usr/local/bin/macbash + - /usr/local/bin/git-scrub + - /usr/local/go/bin/go + register: verify_managed_copies + + - name: Assert the managed copies survived + ansible.builtin.assert: + that: + - item.stat.exists + fail_msg: "{{ item.item }} is gone -- a duplicate sweep removed the managed copy." + success_msg: "{{ item.item }} intact" + loop: "{{ verify_managed_copies.results }}" + loop_control: + label: "{{ item.item }}" + + - name: Re-read the installed packages (Ubuntu) + ansible.builtin.package_facts: + manager: auto + when: ansible_facts['distribution'] == 'Ubuntu' + + # golang-1.26-go is whichever versioned package /usr/bin/go resolved to. + - name: Assert the hand-installed and distro duplicates were purged (Ubuntu) + ansible.builtin.assert: + that: + - ansible_facts.packages.keys() | select('match', item) | list | length == 0 + fail_msg: "{{ item }} is still installed beside the managed copy it duplicates." + success_msg: "{{ item }} purged" + loop: + - '^macbash$' + - '^golang-go$' + - '^golang-src$' + - '^golang-[0-9.]+-go$' + when: ansible_facts['distribution'] == 'Ubuntu' + + - name: Assert a duplicate package with a dependant was kept (Ubuntu) + ansible.builtin.assert: + that: + - "'git-scrub' in ansible_facts.packages" + - "'git-scrub-dependant' in ansible_facts.packages" + fail_msg: >- + The git-scrub package was removed although git-scrub-dependant needs it -- + removing it took a package nobody asked us to touch. + success_msg: "git-scrub kept for its dependant" + when: ansible_facts['distribution'] == 'Ubuntu' + # PATH hygiene is NOT asserted here. The tasks that drop the unguarded # ~/.bashrc prepends live in the install path of the developer and # developer-rust roles, not under the `removals` tag this scenario diff --git a/ansible/roles/contributor/tasks/hyperi_ci.yml b/ansible/roles/contributor/tasks/hyperi_ci.yml index ce3562d..e3c7440 100644 --- a/ansible/roles/contributor/tasks/hyperi_ci.yml +++ b/ansible/roles/contributor/tasks/hyperi_ci.yml @@ -103,19 +103,21 @@ # of THIS repo (see CONTRIBUTING), not something hyperi-ci calls. # # Not packaged anywhere, so cargo it is -- the bottom rung, and the only rung. +# +# Into the effective CARGO_HOME developer-rust resolved, where `cargo +# install-update` finds it; ~/.cargo when that role is not in the run. - name: Install alint (repository-structure linter, NOT ansible-lint) ansible.builtin.command: cmd: cargo install alint - creates: "{{ user_home }}/.cargo/bin/alint" + creates: "{{ developer_rust_cargo_home | default(user_home ~ '/.cargo') }}/bin/alint" become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" - environment: - # /opt/homebrew/opt/rustup/bin: brew's rustup never links its shims into - # /opt/homebrew/bin, so without it no cargo resolves on a rustup Mac. This - # task has no distribution gate, so it runs there too. - PATH: >- - {{ user_home }}/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:/usr/local/bin:{{ - ansible_facts['env'].PATH }} + # /opt/homebrew/opt/rustup/bin: brew's rustup never links its shims into + # /opt/homebrew/bin, so without it no cargo resolves on a rustup Mac. This + # task has no distribution gate, so it runs there too. + environment: >- + {{ cargo_env | default({'PATH': user_home ~ '/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:/usr/local/bin:' + ~ ansible_facts['env'].PATH}) }} register: contributor_alint changed_when: "'Installed package' in contributor_alint.stdout | default('')" failed_when: false diff --git a/ansible/roles/contributor/tasks/typos.yml b/ansible/roles/contributor/tasks/typos.yml index f5dd172..22432a0 100644 --- a/ansible/roles/contributor/tasks/typos.yml +++ b/ansible/roles/contributor/tasks/typos.yml @@ -14,14 +14,16 @@ environment: "{{ homebrew_env }}" when: ansible_facts['distribution'] == 'MacOSX' +# Into the effective CARGO_HOME developer-rust resolved, where `cargo +# install-update` finds it; ~/.cargo when that role is not in the run. - name: Install typos (Linux — cargo) ansible.builtin.command: cmd: cargo install typos-cli - creates: "{{ user_home }}/.cargo/bin/typos" + creates: "{{ developer_rust_cargo_home | default(user_home ~ '/.cargo') }}/bin/typos" become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" - environment: - PATH: "{{ user_home }}/.cargo/bin:/usr/local/bin:{{ ansible_facts['env'].PATH }}" + environment: >- + {{ cargo_env | default({'PATH': user_home ~ '/.cargo/bin:/usr/local/bin:' ~ ansible_facts['env'].PATH}) }} register: contributor_typos changed_when: "'Installed package' in contributor_typos.stdout | default('')" failed_when: false diff --git a/ansible/roles/contributor/tasks/verify.yml b/ansible/roles/contributor/tasks/verify.yml index fd23f4d..6c7241d 100644 --- a/ansible/roles/contributor/tasks/verify.yml +++ b/ansible/roles/contributor/tasks/verify.yml @@ -29,7 +29,9 @@ - name: Check the optional hyperi-ci check tools ansible.builtin.command: "{{ item }} --version" environment: - PATH: "{{ user_home }}/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:{{ user_home }}/.local/bin:{{ ansible_facts['env'].PATH }}" + PATH: >- + {{ developer_rust_cargo_home | default(user_home ~ '/.cargo') }}/bin:/opt/homebrew/bin:/usr/local/bin:{{ + user_home }}/.local/bin:{{ ansible_facts['env'].PATH }} loop: - hyperi-ci - semgrep diff --git a/ansible/roles/developer-go/tasks/main.yml b/ansible/roles/developer-go/tasks/main.yml index c17c305..2d43727 100644 --- a/ansible/roles/developer-go/tasks/main.yml +++ b/ansible/roles/developer-go/tasks/main.yml @@ -7,3 +7,13 @@ apply: tags: ['developer-go', 'go'] tags: ['developer-go', 'go'] + +# After go.yml, so /usr/local/go is in before the distro Go it supersedes goes. +# `removals` also reaches it, for a remediation run that installs nothing. +- name: Remove the distro Go superseded by the managed toolchain + ansible.builtin.include_tasks: + file: superseded.yml + apply: + tags: ['developer-go', 'go', 'removals'] + tags: ['developer-go', 'go', 'removals'] + when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] diff --git a/ansible/roles/developer-go/tasks/superseded.yml b/ansible/roles/developer-go/tasks/superseded.yml new file mode 100644 index 0000000..c893648 --- /dev/null +++ b/ansible/roles/developer-go/tasks/superseded.yml @@ -0,0 +1,125 @@ +--- +# The distro Go beside the upstream toolchain this role installs in /usr/local/go. +# /usr/bin/go answers wherever /etc/profile.d has not run -- a cron job, a +# systemd unit, an IDE launched outside a login shell -- so a build there gets +# the distro's Go instead of the managed one. +# +# Removed only once /usr/local/go/bin/go resolves, only where /usr/bin/go +# resolves outside /usr/local (a link someone pointed at the managed toolchain is +# not the distro's), and only when the removal touches nothing beyond the Go +# packages themselves: a package that needs the distro Go keeps it, and the run +# says which. +# +# Linux only: Homebrew is the managed Go on macOS. + +- name: Resolve the managed and the distro Go + ansible.builtin.command: + argv: [readlink, -e, "{{ item }}"] + loop: + - /usr/local/go/bin/go + - /usr/bin/go + register: developer_go_resolved + changed_when: false + failed_when: false + check_mode: false + +- name: Find the packages behind the distro Go + when: + - developer_go_resolved.results[0].rc == 0 + - developer_go_resolved.results[1].rc == 0 + - not developer_go_resolved.results[1].stdout.startswith('/usr/local/') + block: + # The package that ships the resolved binary (golang-1.26-go on Ubuntu, + # golang-bin on Fedora), plus the metapackages that pull it in. + - name: Find the package that ships the distro Go + ansible.builtin.command: + argv: >- + {{ (['dpkg-query', '-S'] if ansible_facts['distribution'] == 'Ubuntu' + else ['rpm', '-qf', '--qf', '%{NAME}\n']) + [developer_go_resolved.results[1].stdout] }} + register: developer_go_owner + changed_when: false + failed_when: false + check_mode: false + + - name: List the installed distro Go packages + ansible.builtin.command: + argv: >- + {{ (['dpkg-query', '-W', '-f', '${db:Status-Abbrev} ${Package}\n'] + if ansible_facts['distribution'] == 'Ubuntu' + else ['rpm', '-q', '--qf', '%{NAME}\n']) + + developer_go_candidates }} + register: developer_go_installed + changed_when: false + failed_when: false + check_mode: false + when: developer_go_owner.rc == 0 + vars: + developer_go_owned: "{{ developer_go_owner.stdout_lines | first | regex_replace(':.*$', '') }}" + # golang-1.26-go -> golang-1.26-src beside it; Fedora splits bin and src. + developer_go_candidates: >- + {{ (['golang', 'golang-go', 'golang-src', 'golang-doc', developer_go_owned, + developer_go_owned | regex_replace('-go$', '-src'), + developer_go_owned | regex_replace('-go$', '-doc')] + if ansible_facts['distribution'] == 'Ubuntu' + else ['golang', 'golang-src', developer_go_owned]) | unique }} + + # dpkg-query reports `ii ` for an installed package; rpm prints the name of + # each installed one and `package X is not installed` for the rest. + - name: Collect the distro Go packages to remove + ansible.builtin.set_fact: + developer_go_distro_pkgs: >- + {{ (developer_go_installed.stdout_lines | default([]) | select('match', '^ii ') + | map('regex_replace', '^ii +', '') | list) + if ansible_facts['distribution'] == 'Ubuntu' + else (developer_go_installed.stdout_lines | default([]) | reject('search', ' ') | list) }} + + - name: Simulate removing the distro Go + ansible.builtin.command: + argv: >- + {{ (['apt-get', '-s', 'purge'] if ansible_facts['distribution'] == 'Ubuntu' + else ['rpm', '-e', '--test']) + developer_go_distro_pkgs }} + register: developer_go_sim + changed_when: false + failed_when: false + check_mode: false + when: developer_go_distro_pkgs | length > 0 + + - name: Note what removing the distro Go would also take + ansible.builtin.set_fact: + developer_go_sim_also: >- + {{ developer_go_sim.stdout_lines | default([]) | select('match', '^(Purg|Remv) ') + | map('regex_replace', '^\S+ (\S+).*$', '\1') + | reject('in', developer_go_distro_pkgs) | list }} + when: developer_go_distro_pkgs | length > 0 + + - name: Purge the distro Go superseded by /usr/local/go (Ubuntu) + ansible.builtin.apt: + name: "{{ developer_go_distro_pkgs }}" + state: absent + purge: true + autoremove: false + when: + - ansible_facts['distribution'] == 'Ubuntu' + - developer_go_distro_pkgs | length > 0 + - developer_go_sim.rc == 0 + - developer_go_sim_also | length == 0 + + - name: Remove the distro Go superseded by /usr/local/go (Fedora) + ansible.builtin.dnf: + name: "{{ developer_go_distro_pkgs }}" + state: absent + autoremove: false + when: + - ansible_facts['distribution'] == 'Fedora' + - developer_go_distro_pkgs | length > 0 + - developer_go_sim.rc == 0 + + - name: Report a distro Go kept because something depends on it + ansible.builtin.debug: + msg: >- + Kept the distro Go ({{ developer_go_distro_pkgs | join(', ') }}) beside + /usr/local/go: removing it would also remove + {{ developer_go_sim_also | join(', ') if developer_go_sim_also else developer_go_sim.stderr | trim }}. + when: + - developer_go_distro_pkgs | length > 0 + - developer_go_sim.rc != 0 or developer_go_sim_also | length > 0 diff --git a/ansible/roles/developer-rust/README.md b/ansible/roles/developer-rust/README.md index d309b7e..77e1f04 100644 --- a/ansible/roles/developer-rust/README.md +++ b/ansible/roles/developer-rust/README.md @@ -166,13 +166,15 @@ This does not reopen the objection in the SSoT note below: crates.io stays out of the global `rustc-wrapper` path. The binary is the project's own release artefact, digest-checked, not an unpinned `cargo install`. -**A cargo-installed sccache still shadows it.** `~/.cargo/bin` precedes -`/usr/local/bin` on PATH, so anything typed by hand reaches the cargo copy -while builds keep using the absolute path in the cargo config. That split is -what makes a failed `--show-stats` look like a dead cache when every build is -being cached normally. The prune reports which binary builds use and queries -that one; the setup tool prints the `cargo uninstall` line. Removing a binary a -developer installed is their call. +**A cargo-installed sccache would shadow it.** `~/.cargo/bin` precedes `/usr/local/bin` on PATH, so anything typed by hand reaches the cargo copy while builds keep using the absolute path in the cargo config. That split is what makes a failed `--show-stats` look like a dead cache when every build is being cached normally. The role deregisters and removes the cargo copy once the managed one is in place (`tasks/strays.yml`). The setup tool run on its own only prints the `cargo uninstall` line. + +**Strays.** After the installs, the role clears what would otherwise run instead of the managed copies, in the effective `CARGO_HOME` and, where `CARGO_HOME` is relocated, in the old `~/.cargo`: + +- cargo-home copies of sccache, sd, fnm, uv and uvx, once the managed copy exists and is a different file +- the retired cargo-tarpaulin +- binaries in a superseded `~/.cargo/bin` that the effective home also holds (its registry and git caches, install record and anything installed only there stay) + +A cargo-installed copy goes through `cargo uninstall --root`, so `cargo install-update` does not reinstall it. `build.build-dir` is stable from Rust 1.91. On an older toolchain the setup tool says so and leaves the per-project layout alone, so the default stays safe. @@ -231,10 +233,6 @@ workstation. **Install mold on macOS.** mold is an ELF linker with no Mach-O backend, so it cannot link anything built natively on a Mac. -**Remove a cargo-installed sccache that shadows the packaged one.** The tool -detects the shadow and prints the `cargo uninstall` line. Removing a binary a -developer installed themselves is their call. - ## Taking over an existing config The tool manages exactly two tables, `[build]` and `[target]`. Every other table diff --git a/ansible/roles/developer-rust/tasks/rust.yml b/ansible/roles/developer-rust/tasks/rust.yml index 847878a..7a00ce3 100644 --- a/ansible/roles/developer-rust/tasks/rust.yml +++ b/ansible/roles/developer-rust/tasks/rust.yml @@ -106,6 +106,33 @@ quiet: true tags: ['rust-cache', 'rust-governor', 'rust-llvm'] +# Compared by device and inode, not by path: ~/.cargo can be a symlink to the +# relocated home, and then it is the live home, not a stale one. +- name: Identify the default and the effective cargo homes + ansible.builtin.stat: + path: "{{ item }}" + follow: true + get_checksum: false + loop: + - "{{ user_home }}/.cargo" + - "{{ developer_rust_cargo_home }}" + become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" + register: developer_rust_cargo_home_dirs + tags: ['rust-cache', 'rust-governor', 'rust-llvm'] + +# True when ~/.cargo is a separate directory from the effective CARGO_HOME: +# whatever it still holds is left over from before the relocation. +- name: Note whether ~/.cargo is a stale home beside a relocated one + ansible.builtin.set_fact: + developer_rust_cargo_home_stale: >- + {{ _default.exists and _default.isdir + and not (_effective.exists and _effective.dev == _default.dev and _effective.inode == _default.inode) }} + vars: + _default: "{{ developer_rust_cargo_home_dirs.results[0].stat }}" + _effective: "{{ developer_rust_cargo_home_dirs.results[1].stat }}" + tags: ['rust-cache', 'rust-governor', 'rust-llvm'] + - name: Report where the toolchain was resolved to ansible.builtin.debug: msg: >- @@ -177,8 +204,8 @@ mode: '0755' checksum: "sha256:{{ developer_rustup_sha.content.split() | first }}" - # --no-modify-path: this role already manages the ~/.cargo/bin PATH - # entry in .bashrc below, and letting rustup-init add its own leaves two. + # --no-modify-path: this role already manages the cargo bin PATH entry + # in ~/.profile below, and letting rustup-init add its own leaves two. - name: Install the Rust toolchain via rustup-init ansible.builtin.command: cmd: /tmp/rustup-init -y --default-toolchain stable --no-modify-path @@ -730,16 +757,6 @@ ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] or developer_rust_macos_rustup.rc | default(1) == 0 -# hyperi-ci prefers tarpaulin over llvm-cov when both are present, so leaving a -# stale copy behind would keep Linux on the tool macOS cannot run. -- name: Remove the superseded cargo-tarpaulin - ansible.builtin.command: - cmd: cargo uninstall cargo-tarpaulin - removes: "{{ developer_rust_cargo_home }}/bin/cargo-tarpaulin" - environment: "{{ cargo_env }}" - become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" - become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" - - name: Install cargo-pgo (profile-guided optimisation) ansible.builtin.command: cmd: cargo install cargo-pgo --locked @@ -897,6 +914,19 @@ when: developer_rust_setup.rc | default(0) != 0 tags: ['rust-cache'] +# ============================================================ +# Strays: duplicates, retired tools, a superseded ~/.cargo +# ============================================================ +# After every install above and after hyperi-rust-setup, so each managed copy +# is in place before the copy it supersedes goes, and the cargo config already +# names the managed sccache rather than one about to be removed. +- name: Remove cargo-home strays + ansible.builtin.include_tasks: + file: strays.yml + apply: + tags: ['developer-rust', 'rust'] + tags: ['developer-rust', 'rust'] + # ============================================================ # Post-condition: the toolchain the converge just left behind works # ============================================================ @@ -988,7 +1018,7 @@ register: developer_rust_retired when: - rust_retire_superseded_config | default(true) | bool - - developer_rust_cargo_home != user_home ~ '/.cargo' + - developer_rust_cargo_home_stale | default(false) | bool # Fatal even on the run that just retired the offending file. The toolchain # WAS broken, and a converge that repairs it and then says so is the loud diff --git a/ansible/roles/developer-rust/tasks/strays.yml b/ansible/roles/developer-rust/tasks/strays.yml new file mode 100644 index 0000000..a0e41aa --- /dev/null +++ b/ansible/roles/developer-rust/tasks/strays.yml @@ -0,0 +1,174 @@ +--- +# Binaries in a cargo home that duplicate a managed install, that this role has +# retired, or that a relocated CARGO_HOME left behind in ~/.cargo. +# +# A cargo-installed binary is deregistered with `cargo uninstall --root` before +# anything is deleted: `cargo install-update -a` reinstalls whatever the home's +# install record still lists, so deleting the file alone brings it back with the +# next upstream release. The file itself goes afterwards for a binary cargo has +# no record of, which is what an installer script or a copied directory leaves. +# +# A copy goes only when the copy it duplicates exists and is a different file +# (device and inode after following links), so the only working copy of a tool +# is never the one removed. + +- name: Name the cargo homes to sweep + ansible.builtin.set_fact: + developer_rust_swept_homes: >- + {{ [developer_rust_cargo_home] + + ([user_home ~ '/.cargo'] if developer_rust_cargo_home_stale | default(false) | bool else []) }} + +# ============================================================ +# Duplicates of a managed install (Linux) +# ============================================================ +# sccache is the /usr/local/bin release hyperi-rust-setup installs. uv is the +# astral role's (~/.local/bin on Ubuntu, dnf on Fedora); its old installer +# script put uv and uvx in the cargo home. sd and fnm are crates of tools the +# developer role installs system-wide. +- name: Remove cargo-home copies of managed tools (Linux) + when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] + vars: + developer_rust_managed_dupes: + - {bin: sccache, crate: sccache, managed: [/usr/local/bin/sccache]} + - {bin: sd, crate: sd, managed: [/usr/local/bin/sd, /usr/bin/sd]} + - {bin: fnm, crate: fnm, managed: [/usr/local/bin/fnm]} + - {bin: uv, managed: ["{{ user_home }}/.local/bin/uv", /usr/bin/uv]} + - {bin: uvx, managed: ["{{ user_home }}/.local/bin/uvx", /usr/bin/uvx]} + developer_rust_dupe_paths: >- + {{ (developer_rust_managed_dupes | map(attribute='managed') | flatten) + + (developer_rust_swept_homes | map('regex_replace', '$', '/bin') + | product(developer_rust_managed_dupes | map(attribute='bin')) | map('join', '/') | list) }} + # path -> "device:inode" for every candidate that resolves to a file. + developer_rust_dupe_ids: >- + {{ dict(developer_rust_dupe_stat.stdout_lines | default([]) | map('regex_replace', '^\S+ ', '') + | zip(developer_rust_dupe_stat.stdout_lines | default([]) | map('regex_replace', ' .*$', ''))) }} + block: + # Missing paths make stat exit non-zero and drop out of the output. + - name: Identify the managed tools and their cargo-home copies + ansible.builtin.command: + argv: "{{ ['stat', '-L', '--printf', '%d:%i %n\\n', '--'] + developer_rust_dupe_paths }}" + become: true + become_user: "{{ actual_user }}" + register: developer_rust_dupe_stat + changed_when: false + failed_when: false + check_mode: false + + - name: Deregister cargo-home copies of managed tools + ansible.builtin.command: + argv: [cargo, uninstall, --root, "{{ item.0 }}", "{{ item.1.crate }}"] + environment: "{{ cargo_env }}" + become: true + become_user: "{{ actual_user }}" + loop: "{{ developer_rust_swept_homes | product(developer_rust_managed_dupes) | list }}" + loop_control: + label: "{{ item.0 }}/bin/{{ item.1.bin }}" + register: developer_rust_dupe_uninstall + changed_when: "'Removing' in developer_rust_dupe_uninstall.stderr | default('')" + # Not registered with cargo: the file removal below takes it. + failed_when: false + when: + - item.1.crate is defined + - developer_rust_dupe_path in developer_rust_dupe_ids + - developer_rust_dupe_managed | length > 0 + - developer_rust_dupe_ids[developer_rust_dupe_path] not in developer_rust_dupe_managed + vars: + developer_rust_dupe_path: "{{ item.0 }}/bin/{{ item.1.bin }}" + developer_rust_dupe_managed: >- + {{ item.1.managed | select('in', developer_rust_dupe_ids) + | map('extract', developer_rust_dupe_ids) | list }} + + - name: Remove cargo-home copies of managed tools + ansible.builtin.file: + path: "{{ item.0 }}/bin/{{ item.1.bin }}" + state: absent + become: true + become_user: "{{ actual_user }}" + loop: "{{ developer_rust_swept_homes | product(developer_rust_managed_dupes) | list }}" + loop_control: + label: "{{ item.0 }}/bin/{{ item.1.bin }}" + when: + - developer_rust_dupe_path in developer_rust_dupe_ids + - developer_rust_dupe_managed | length > 0 + - developer_rust_dupe_ids[developer_rust_dupe_path] not in developer_rust_dupe_managed + vars: + developer_rust_dupe_path: "{{ item.0 }}/bin/{{ item.1.bin }}" + developer_rust_dupe_managed: >- + {{ item.1.managed | select('in', developer_rust_dupe_ids) + | map('extract', developer_rust_dupe_ids) | list }} + +# ============================================================ +# Retired tools +# ============================================================ +# Coverage is cargo-llvm-cov. hyperi-ci prefers tarpaulin when both are present, +# so a stale copy keeps Linux on the tool macOS cannot run. +- name: Deregister the superseded cargo-tarpaulin + ansible.builtin.command: + argv: [cargo, uninstall, --root, "{{ item }}", cargo-tarpaulin] + removes: "{{ item }}/bin/cargo-tarpaulin" + environment: "{{ cargo_env }}" + become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" + loop: "{{ developer_rust_swept_homes }}" + register: developer_rust_tarpaulin_uninstall + changed_when: "'Removing' in developer_rust_tarpaulin_uninstall.stderr | default('')" + # Not registered with cargo: the file removal below takes it. + failed_when: false + +- name: Remove the superseded cargo-tarpaulin + ansible.builtin.file: + path: "{{ item }}/bin/cargo-tarpaulin" + state: absent + become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" + loop: "{{ developer_rust_swept_homes }}" + +# ============================================================ +# What a relocated CARGO_HOME left in ~/.cargo +# ============================================================ +# ~/.cargo/bin stays on PATH wherever a profile still names it, so a tool left +# there runs instead of the copy in the effective home, which is the one the +# installs and `cargo install-update` keep current. Only a binary the effective +# home also holds goes. The registry and git caches, the install record and +# anything installed only there stay: they may be the developer's own. +- name: Remove binaries the effective cargo home supersedes + when: developer_rust_cargo_home_stale | default(false) | bool + block: + - name: List the binaries left in ~/.cargo/bin + ansible.builtin.find: + paths: "{{ user_home }}/.cargo/bin" + file_type: any + become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" + register: developer_rust_stale_bins + + # find reports a link's own inode, so both sides are stat'd through links. + - name: Identify each ~/.cargo/bin binary and its effective-home namesake + ansible.builtin.stat: + path: "{{ item }}" + follow: true + get_checksum: false + become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" + loop: >- + {{ developer_rust_stale_bins.files | map(attribute='path') | sort + | map('regex_replace', '^.*/', developer_rust_cargo_home ~ '/bin/') | list + + (developer_rust_stale_bins.files | map(attribute='path') | sort | list) }} + register: developer_rust_bin_pairs + + - name: Remove ~/.cargo/bin binaries the effective home also holds + ansible.builtin.file: + path: "{{ item.1.item }}" + state: absent + become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" + # First half of the stat results is the effective home, second half the stale one. + loop: >- + {{ developer_rust_bin_pairs.results[:developer_rust_stale_bins.matched] + | zip(developer_rust_bin_pairs.results[developer_rust_stale_bins.matched:]) | list }} + loop_control: + label: "{{ item.1.item }}" + when: + - item.0.stat.exists + - item.1.stat.exists + - item.0.stat.dev != item.1.stat.dev or item.0.stat.inode != item.1.stat.inode diff --git a/ansible/roles/developer/files/update/hyperi-update-linux.sh b/ansible/roles/developer/files/update/hyperi-update-linux.sh index 3ec92e3..2de49f3 100644 --- a/ansible/roles/developer/files/update/hyperi-update-linux.sh +++ b/ansible/roles/developer/files/update/hyperi-update-linux.sh @@ -35,9 +35,9 @@ set -uo pipefail # Make user-level tools reachable even when launched from a GUI/.desktop entry -# that doesn't source the login shell (uv/rustup live in ~/.cargo/bin, claude in -# ~/.local/bin). -export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" +# that doesn't source the login shell (rustup and the cargo tools live in the +# cargo home, uv and claude in ~/.local/bin, Go in /usr/local/go/bin). +export PATH="$HOME/.local/bin:${CARGO_HOME:-$HOME/.cargo}/bin:/usr/local/go/bin:$PATH" ASSUME_YES=0 diff --git a/ansible/roles/developer/files/update/hyperi-update-macos.sh b/ansible/roles/developer/files/update/hyperi-update-macos.sh index a7b5e3f..efda187 100644 --- a/ansible/roles/developer/files/update/hyperi-update-macos.sh +++ b/ansible/roles/developer/files/update/hyperi-update-macos.sh @@ -41,7 +41,7 @@ emulate -L zsh # Make user-level tools reachable even when launched from the GUI app or a # non-login shell (Ansible): brew lives outside the base PATH on both Apple # silicon and Intel. -export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" +export PATH="$HOME/.local/bin:${CARGO_HOME:-$HOME/.cargo}/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" ASSUME_YES=0 diff --git a/ansible/roles/developer/tasks/removals.yml b/ansible/roles/developer/tasks/removals.yml index 4d98548..7f87d4d 100644 --- a/ansible/roles/developer/tasks/removals.yml +++ b/ansible/roles/developer/tasks/removals.yml @@ -77,6 +77,223 @@ become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user }}" +# Hand-installed copies of tools the roles install system-wide. ~/.local/bin and +# ~/go/bin precede /usr/local/bin and /usr/bin on PATH, so a copy left there +# shadows the managed install and no update ever reaches it. +# +# A copy goes only when the system copy exists and is a different file (device +# and inode after following links): a user-level link to the managed binary is +# not a duplicate, and with no system copy the user's is the only one. Only the +# user-level path is removed, never what a link points at. Cargo homes are left +# to developer-rust, which knows the effective CARGO_HOME and deregisters a +# cargo-installed copy so `cargo install-update` does not bring it back. +# +# Linux only: a Mac's ~/.local/bin is the developer's own, and brew owns the +# system copies there. +# +# `local` names the distros where the managed copy is the /usr/local/bin +# binary; a distro package of the same tool there is a duplicate (below). +- name: Remove hand-installed duplicates of role-managed tools (Linux) + when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] + vars: + developer_managed_tools: + - {bin: kind, local: [Fedora, Ubuntu]} + - {bin: argocd, local: [Fedora, Ubuntu]} + - {bin: dive, local: [Fedora, Ubuntu]} + - {bin: kubeconform, local: [Fedora, Ubuntu]} + - {bin: kube-linter, local: [Fedora, Ubuntu]} + - {bin: k9s, local: [Ubuntu]} + - {bin: kustomize, local: [Ubuntu]} + - {bin: kubectx, local: [Fedora]} + - {bin: kubens, local: [Fedora]} + - {bin: yq, local: [Ubuntu]} + - {bin: gron, local: [Fedora]} + - {bin: sd, local: [Fedora]} + - {bin: lazygit, local: [Fedora, Ubuntu]} + - {bin: fnm, local: [Fedora, Ubuntu]} + - {bin: terraform-docs, local: [Fedora, Ubuntu]} + - {bin: aws-vault, local: [Fedora, Ubuntu]} + - {bin: golangci-lint, local: [Fedora, Ubuntu]} + - {bin: actionlint, local: [Fedora, Ubuntu]} + - {bin: hadolint, local: [Ubuntu]} + - {bin: gitleaks, local: [Ubuntu]} + - {bin: act, local: [Ubuntu]} + - {bin: osv-scanner, local: [Fedora, Ubuntu]} + - {bin: git-scrub, local: [Fedora, Ubuntu]} + - {bin: macbash, local: [Fedora, Ubuntu]} + - {bin: tea, local: [Fedora, Ubuntu]} + - {bin: sccache, local: [Fedora, Ubuntu]} + # Vendor-repository packages: the managed copy is /usr/bin. + - {bin: kubectl, local: []} + - {bin: helm, local: []} + - {bin: rpk, local: []} + - {bin: tofu, local: []} + - {bin: bao, local: []} + developer_user_bin_dirs: + - "{{ user_home }}/.local/bin" + - "{{ user_home }}/go/bin" + developer_tool_names: "{{ developer_managed_tools | map(attribute='bin') | list }}" + developer_tool_paths: >- + {{ (['/usr/local/bin', '/usr/bin'] + developer_user_bin_dirs) + | product(developer_tool_names) | map('join', '/') | list }} + # path -> "device:inode" for every candidate that resolves to a file. + developer_tool_ids: >- + {{ dict(developer_tool_stat.stdout_lines | default([]) | map('regex_replace', '^\S+ ', '') + | zip(developer_tool_stat.stdout_lines | default([]) | map('regex_replace', ' .*$', ''))) }} + block: + # One stat for every candidate. Missing paths make stat exit non-zero and + # simply drop out of the output. + - name: Identify the files behind role-managed tools and their user-level copies + ansible.builtin.command: + argv: "{{ ['stat', '-L', '--printf', '%d:%i %n\\n', '--'] + developer_tool_paths }}" + become: true + become_user: "{{ actual_user }}" + register: developer_tool_stat + changed_when: false + failed_when: false + check_mode: false + + - name: Remove user-level copies that shadow a role-managed tool + ansible.builtin.file: + path: "{{ item.0 }}/{{ item.1 }}" + state: absent + become: true + become_user: "{{ actual_user }}" + loop: "{{ developer_user_bin_dirs | product(developer_tool_names) | list }}" + loop_control: + label: "{{ item.0 }}/{{ item.1 }}" + when: + - (item.0 ~ '/' ~ item.1) in developer_tool_ids + - developer_tool_system_id | length > 0 + - developer_tool_ids[item.0 ~ '/' ~ item.1] != developer_tool_system_id + vars: + developer_tool_system_id: >- + {{ ((['/usr/local/bin/' ~ item.1, '/usr/bin/' ~ item.1] | select('in', developer_tool_ids) + | map('extract', developer_tool_ids) | list) + [''])[0] }} + + # Where the managed copy is the /usr/local/bin binary, a package that also + # ships /usr/bin/ is a .deb or .rpm installed by hand beside it. The + # /usr/local/bin copy has to resolve under /usr/local, so purging the + # package cannot take the file it points at. + - name: Resolve the /usr/local/bin copies that have a /usr/bin twin + ansible.builtin.command: + argv: [readlink, -e, "/usr/local/bin/{{ item }}"] + loop: >- + {{ developer_managed_tools | selectattr('local', 'contains', ansible_facts['distribution']) + | map(attribute='bin') | list }} + register: developer_tool_local_real + changed_when: false + failed_when: false + check_mode: false + when: + - ('/usr/local/bin/' ~ item) in developer_tool_ids + - ('/usr/bin/' ~ item) in developer_tool_ids + - developer_tool_ids['/usr/local/bin/' ~ item] != developer_tool_ids['/usr/bin/' ~ item] + + - name: Find the package that ships each /usr/bin twin + ansible.builtin.command: + argv: >- + {{ (['dpkg-query', '-S'] if ansible_facts['distribution'] == 'Ubuntu' + else ['rpm', '-qf', '--qf', '%{NAME}\n']) + ['/usr/bin/' ~ item.item] }} + loop: "{{ developer_tool_local_real.results }}" + loop_control: + label: "{{ item.item }}" + register: developer_tool_owner + changed_when: false + failed_when: false + check_mode: false + when: + - item.rc | default(1) == 0 + - item.stdout.startswith('/usr/local/') + + # dpkg-query prints `pkg: path` or `pkg:arch: path`; rpm the bare name. A + # diverted path adds a `diversion by` line naming no package. + - name: Collect the packages that duplicate a /usr/local/bin tool + ansible.builtin.set_fact: + developer_tool_dup_pkgs: >- + {{ developer_tool_owner.results | selectattr('rc', 'defined') | selectattr('rc', 'eq', 0) + | map(attribute='stdout_lines') | flatten | reject('match', '^diversion ') + | map('regex_replace', ':.*$', '') | unique | list }} + + # A package some repository still offers is the distro's or a vendor's, + # and updates arrive for it -- not a hand install. Ubuntu reads the cached + # lists; Fedora asks the enabled repos, and a query that fails keeps it. + - name: Check whether any repository offers each duplicate package + ansible.builtin.command: + argv: >- + {{ (['apt-cache', 'madison'] if ansible_facts['distribution'] == 'Ubuntu' + else ['dnf', '-q', 'repoquery', '--available', '--qf', '%{name}\n']) + [item] }} + loop: "{{ developer_tool_dup_pkgs }}" + register: developer_tool_dup_origin + changed_when: false + failed_when: false + check_mode: false + + # Simulated first, so a package something else depends on stays: apt would + # remove the dependants with it, dnf likewise. + - name: Simulate removing each hand-installed duplicate package + ansible.builtin.command: + argv: >- + {{ (['apt-get', '-s', 'purge'] if ansible_facts['distribution'] == 'Ubuntu' + else ['rpm', '-e', '--test']) + [item.item] }} + loop: "{{ developer_tool_dup_origin.results }}" + loop_control: + label: "{{ item.item }}" + register: developer_tool_dup_sim + changed_when: false + failed_when: false + check_mode: false + when: + - item.rc == 0 + - item.stdout | trim | length == 0 + + - name: Purge hand-installed packages that duplicate a /usr/local/bin tool (Ubuntu) + ansible.builtin.apt: + name: "{{ item.item.item }}" + state: absent + purge: true + autoremove: false + loop: "{{ developer_tool_dup_sim.results }}" + loop_control: + label: "{{ item.item.item }}" + when: + - ansible_facts['distribution'] == 'Ubuntu' + - item.rc | default(1) == 0 + - developer_tool_dup_also | length == 0 + vars: + developer_tool_dup_also: >- + {{ item.stdout_lines | default([]) | select('match', '^(Purg|Remv) ') + | map('regex_replace', '^\S+ (\S+).*$', '\1') | reject('equalto', item.item.item | default('')) | list }} + + - name: Remove hand-installed packages that duplicate a /usr/local/bin tool (Fedora) + ansible.builtin.dnf: + name: "{{ item.item.item }}" + state: absent + autoremove: false + loop: "{{ developer_tool_dup_sim.results }}" + loop_control: + label: "{{ item.item.item }}" + when: + - ansible_facts['distribution'] == 'Fedora' + - item.rc | default(1) == 0 + + - name: Report duplicate packages kept because something depends on them + ansible.builtin.debug: + msg: >- + Kept the hand-installed {{ item.item.item }} package beside its + /usr/local/bin copy: removing it would also remove + {{ developer_tool_dup_also | join(', ') if developer_tool_dup_also else item.stderr | trim }}. + loop: "{{ developer_tool_dup_sim.results }}" + loop_control: + label: "{{ item.item.item }}" + when: + - item.rc is defined + - item.rc != 0 or developer_tool_dup_also | length > 0 + vars: + developer_tool_dup_also: >- + {{ item.stdout_lines | default([]) | select('match', '^(Purg|Remv) ') + | map('regex_replace', '^\S+ (\S+).*$', '\1') | reject('equalto', item.item.item | default('')) | list }} + # ============================================================================ # CANNOT CO-EXIST — the old thing actively conflicts with its replacement. # ============================================================================ diff --git a/ansible/roles/soe/tasks/shell_config.yml b/ansible/roles/soe/tasks/shell_config.yml index c89533e..6f008cb 100644 --- a/ansible/roles/soe/tasks/shell_config.yml +++ b/ansible/roles/soe/tasks/shell_config.yml @@ -31,7 +31,7 @@ esac } - hyperi_path_prepend "$HOME/.cargo/bin" # rustup and cargo-installed tools + hyperi_path_prepend "${CARGO_HOME:-$HOME/.cargo}/bin" # rustup and cargo-installed tools hyperi_path_prepend "$HOME/.local/bin" # uv tools, pipx, gext hyperi_path_prepend "$HOME/.npm-global/bin" # npm global tools (semantic-release) diff --git a/docs/install-matrix.md b/docs/install-matrix.md index 8827884..aa78f13 100644 --- a/docs/install-matrix.md +++ b/docs/install-matrix.md @@ -609,6 +609,8 @@ it. Every channel move needs a tombstone for the path it vacated, and the `remediation` molecule scenario asserts the replacement is what `which` resolves to -- not merely that the new thing installed. +**A hand install leaves a second copy too.** The `removals` tag clears copies of the managed tools in `~/.local/bin` and `~/go/bin`, and a hand-installed .deb or .rpm that duplicates a `/usr/local/bin` tool, each only where the managed copy exists and is a different file. developer-go removes the distro Go once `/usr/local/go` is in, and developer-rust clears cargo-home duplicates, cargo-tarpaulin and a `~/.cargo/bin` left behind by a relocated `CARGO_HOME`. A package something else depends on stays, and the run says which. + ## Auto-update Every tool stays current; the mechanism depends on its channel. Three tiers: From e09361036b967a0928ee9b83d0f0787a0b9623c6 Mon Sep 17 00:00:00 2001 From: Derek Date: Tue, 6 Oct 2026 17:08:04 +1100 Subject: [PATCH 2/5] fix: stop duplicate sweeps removing live copies Package removals now go through one task file, system_cleanup/tasks/purge_packages.yml, used by the distro Go purge, the hand-installed package purge and the Docker Desktop tombstone. It refuses a removal that would take any other package, and marks everything the removal would orphan as manually installed first. hyperi-update runs apt-get autoremove and dnf autoremove unattended, so without that gcc, binutils and libc6-dev went at the next update after the Go purge. The distro Go goes only when /usr/local/go/bin/go runs, resolves under /usr/local to a different file than /usr/bin/go, and hyperi-go.sh exists, and only golang* packages are candidates. A /usr/local/go linked into the distro tree had the only Go purged. go and gofmt are now linked into /usr/local/bin so cron, systemd units and non-login shells keep a Go. A hand-installed package is purged only when it is on an allowlist of upstream release packages (macbash, git-scrub, dive, golangci-lint, k9s), no repository offers it, and the apt lists exist. Repository absence alone purged Ubuntu's yq, sccache and gitleaks wherever the lists were empty. The user-level sweep drops yq, tea, sd and act, whose names other programs share, skips directories, dangling links and bin directories that are links or resolve outside the home. hyperi-update refetches a static binary only when the copy in /usr/local/bin is its own, so a user-level copy no longer makes it install a second one. ~/.cargo is swept only when the home the host exports resolves to the effective one. A rust_cargo_home the host does not declare deleted cargo, rustc and rustup from the only cargo directory on PATH. It now leaves ~/.cargo and its config.toml alone and records a warning. The exported homes are probed once in the developer role with printenv, so an unexported shell variable no longer counts, and contributor installs typos and alint into the same home. In the cargo homes, retired and duplicate tools are deregistered before cargo install-update runs, a ~/.cargo package whose binaries all go is deregistered too, and cargo uninstall fails the run unless cargo simply has no record of the package. hyperi-update reads CARGO_HOME and RUSTUP_HOME from the login shell when they are unset, its unit loads /etc/environment, a missing rustup or cargo-install-update beside a cargo home is a failure, and ~/go/bin is on its PATH. --- ansible/molecule/remediation/molecule.yml | 8 + ansible/molecule/remediation/prepare.yml | 182 +++++++-- ansible/molecule/remediation/verify.yml | 101 ++++- ansible/roles/astral/tasks/main.yml | 2 +- ansible/roles/contributor/tasks/hyperi_ci.yml | 14 +- ansible/roles/contributor/tasks/typos.yml | 12 +- ansible/roles/contributor/tasks/verify.yml | 2 +- ansible/roles/developer-go/tasks/go.yml | 22 ++ .../roles/developer-go/tasks/superseded.yml | 101 ++--- ansible/roles/developer-python/tasks/main.yml | 2 +- ansible/roles/developer-rust/README.md | 12 +- ansible/roles/developer-rust/tasks/rust.yml | 115 +++--- ansible/roles/developer-rust/tasks/strays.yml | 142 +++++-- .../files/update/hyperi-update-linux.sh | 39 +- .../files/update/hyperi-update-macos.sh | 2 +- ansible/roles/developer/tasks/init.yml | 46 +++ ansible/roles/developer/tasks/removals.yml | 352 ++++++++++-------- .../soe/templates/hyperi-update.service.j2 | 4 + .../system_cleanup/tasks/purge_packages.yml | 119 ++++++ docs/install-matrix.md | 4 +- 20 files changed, 894 insertions(+), 387 deletions(-) create mode 100644 ansible/roles/system_cleanup/tasks/purge_packages.yml diff --git a/ansible/molecule/remediation/molecule.yml b/ansible/molecule/remediation/molecule.yml index 5f3d545..30dd13d 100644 --- a/ansible/molecule/remediation/molecule.yml +++ b/ansible/molecule/remediation/molecule.yml @@ -33,12 +33,20 @@ driver: # # pre_build_image: false so molecule builds an Ansible-compatible layer over the # stock image. The base ships no python3, and every module needs one. +# +# The golink host differs only in the Go fixture: its /usr/local/go links into +# the distro tree, so it asserts the distro Go is kept (prepare.yml). platforms: - name: hyperi-remediation-ubuntu image: docker.io/library/ubuntu:26.04 pre_build_image: false command: /bin/sleep infinity privileged: false + - name: hyperi-remediation-ubuntu-golink + image: docker.io/library/ubuntu:26.04 + pre_build_image: false + command: /bin/sleep infinity + privileged: false ansible: cfg: diff --git a/ansible/molecule/remediation/prepare.yml b/ansible/molecule/remediation/prepare.yml index e21b3b6..bcd0994 100644 --- a/ansible/molecule/remediation/prepare.yml +++ b/ansible/molecule/remediation/prepare.yml @@ -392,6 +392,8 @@ - argocd - macbash - git-scrub + - yq + - golangci-lint - name: Create the user-level bin directories ansible.builtin.file: @@ -414,26 +416,46 @@ - go/bin/dive # shadows /usr/local/bin/dive -- must go - .local/bin/kubeconform # no system copy here -- the only one, must stay - .local/bin/tinygo-dev # not a tool the roles manage -- must stay + - .local/bin/yq # pip/uv's yq is another program -- must stay - # CONSTRUCTED: a user-level link to the managed binary is not a duplicate. - - name: Link a user-level argocd to the managed copy + # CONSTRUCTED: a user-level link to the managed binary is not a duplicate, + # and a dangling one shadows nothing. + - name: Plant user-level links ansible.builtin.file: - src: /usr/local/bin/argocd - dest: "{{ ansible_facts['env']['HOME'] }}/.local/bin/argocd" + src: "{{ item.src }}" + dest: "{{ ansible_facts['env']['HOME'] }}/.local/bin/{{ item.name }}" state: link + force: true + loop: + - {name: argocd, src: /usr/local/bin/argocd} + - {name: dive, src: /nonexistent/dive} + + # CONSTRUCTED: a directory that happens to carry a managed tool's name. + - name: Plant a directory named like a managed tool + ansible.builtin.file: + path: "{{ ansible_facts['env']['HOME'] }}/.local/bin/kubectx" + state: directory + mode: '0755' # Observed on the reference workstation 2026-10-06: the macbash .deb, from no # repository, beside the role's /usr/local/bin/macbash. CONSTRUCTED: a - # git-scrub package another package depends on, which must stay. + # git-scrub package another package depends on, a kind package that is not + # on the purge list, and a dive package served by a repository -- all three + # must stay. - name: Install hand-built duplicate packages (Ubuntu) when: ansible_facts['distribution'] == 'Ubuntu' block: + - name: Install the package-building tools + ansible.builtin.apt: + name: dpkg-dev + state: present + - name: Create the fixture package trees ansible.builtin.file: path: "/root/dup-fixture/{{ item.0 }}/{{ item.1 }}" state: directory mode: '0755' - loop: "{{ ['macbash', 'git-scrub', 'git-scrub-dependant'] | product(['DEBIAN', 'usr/bin']) | list }}" + loop: "{{ ['macbash', 'git-scrub', 'git-scrub-dependant', 'kind', 'dive'] | product(['DEBIAN', 'usr/bin']) | list }}" - name: Write the fixture control files ansible.builtin.copy: @@ -450,6 +472,8 @@ - {name: macbash, depends: ''} - {name: git-scrub, depends: ''} - {name: git-scrub-dependant, depends: git-scrub} + - {name: kind, depends: ''} + - {name: dive, depends: ''} - name: Write the packaged binaries ansible.builtin.copy: @@ -462,6 +486,8 @@ - macbash - git-scrub - git-scrub-dependant + - kind + - dive - name: Build the fixture packages ansible.builtin.command: @@ -471,39 +497,149 @@ - macbash - git-scrub - git-scrub-dependant + - kind + - dive - - name: Install the fixture packages + - name: Install the hand-installed fixture packages ansible.builtin.apt: deb: "/root/dup-fixture/{{ item }}.deb" loop: - macbash - git-scrub - git-scrub-dependant + - kind - # Observed on the reference workstation 2026-10-06: golang-go with no reverse - # dependencies beside the role's /usr/local/go. A script stands in for the - # managed toolchain. - - name: Install the distro Go (Ubuntu) - ansible.builtin.apt: - name: golang-go + # dive comes from a local repository instead, so a repository offers it. + # Under /srv, not /root: apt fetches as the _apt user. + - name: Create the fixture repository + ansible.builtin.file: + path: /srv/fixture-repo + state: directory + mode: '0755' + + - name: Place the dive package in the fixture repository + ansible.builtin.copy: + src: /root/dup-fixture/dive.deb + dest: /srv/fixture-repo/dive.deb + remote_src: true + mode: '0644' + + - name: Index the fixture repository + ansible.builtin.shell: + cmd: dpkg-scanpackages --multiversion . > Packages + chdir: /srv/fixture-repo + creates: /srv/fixture-repo/Packages + + - name: Add the fixture repository + ansible.builtin.copy: + content: "deb [trusted=yes] file:/srv/fixture-repo ./\n" + dest: /etc/apt/sources.list.d/hyperi-fixture.list + mode: '0644' + + # Only this source: the planted vendor repositories above are + # unreachable, and a full update would fail on them. + - name: Read the fixture repository + ansible.builtin.command: + argv: + - apt-get + - update + - -o + - Dir::Etc::sourcelist=sources.list.d/hyperi-fixture.list + - -o + - Dir::Etc::sourceparts=- + - -o + - APT::Get::List-Cleanup=0 + changed_when: false + + - name: Install dive from the fixture repository + ansible.builtin.apt: + name: dive + state: present + + # Fedora: the macbash rpm, from no repository, and golangci-lint from the + # Fedora repository -- the first must go, the second stay. + - name: Install hand-built and repository duplicates (Fedora) + when: ansible_facts['distribution'] == 'Fedora' + block: + - name: Install the package-building tools and golangci-lint + ansible.builtin.dnf: + name: + - rpm-build + - golangci-lint + state: present + + - name: Write the macbash fixture spec + ansible.builtin.copy: + content: | + Name: macbash + Version: 1.0.0 + Release: 1 + Summary: Stand-in for a hand-installed macbash rpm + License: MIT + BuildArch: noarch + %description + Fixture. + %install + mkdir -p %{buildroot}/usr/bin + printf '#!/bin/sh\necho rpm macbash\n' > %{buildroot}/usr/bin/macbash + chmod 755 %{buildroot}/usr/bin/macbash + %files + /usr/bin/macbash + dest: /root/macbash.spec + mode: '0644' + + - name: Build the macbash fixture rpm + ansible.builtin.command: + cmd: rpmbuild -bb /root/macbash.spec + creates: /root/rpmbuild/RPMS/noarch/macbash-1.0.0-1.noarch.rpm + + - name: Install the macbash fixture rpm outside any repository + ansible.builtin.dnf: + name: /root/rpmbuild/RPMS/noarch/macbash-1.0.0-1.noarch.rpm + disable_gpg_check: true + state: present + + # Observed on the reference workstation 2026-10-06: the distro Go with no + # reverse dependencies beside the role's /usr/local/go. The distro's own + # tree is copied into /usr/local/go, so `go version` runs as the role's + # would. The golink host instead links /usr/local/go INTO the distro tree, + # which is the distro Go and must stay. + - name: Install the distro Go + ansible.builtin.package: + name: "{{ 'golang-go' if ansible_facts['distribution'] == 'Ubuntu' else 'golang' }}" state: present - when: ansible_facts['distribution'] == 'Ubuntu' - - name: Create the stand-in managed Go tree + - name: Resolve the distro Go tree + ansible.builtin.command: + argv: [readlink, -e, /usr/bin/go] + register: prepare_distro_go + changed_when: false + + - name: Copy the distro Go tree into /usr/local/go + ansible.builtin.command: + argv: [cp, -a, "{{ prepare_distro_go.stdout | dirname | dirname }}", /usr/local/go] + creates: /usr/local/go + when: "'golink' not in inventory_hostname" + + - name: Link /usr/local/go into the distro Go tree ansible.builtin.file: - path: /usr/local/go/bin - state: directory - mode: '0755' + src: "{{ prepare_distro_go.stdout | dirname | dirname }}" + dest: /usr/local/go + state: link + when: "'golink' in inventory_hostname" - - name: Plant the stand-in managed Go + # DERIVED: developer-go/tasks/go.yml writes this beside the toolchain. + - name: Plant the managed Go's profile drop-in ansible.builtin.copy: content: | - #!/bin/sh - echo "go version go0.0.0-fixture linux/amd64" - dest: /usr/local/go/bin/go + case ":$PATH:" in + *":/usr/local/go/bin:"*) ;; + *) export PATH="/usr/local/go/bin:$PATH" ;; + esac + dest: /etc/profile.d/hyperi-go.sh owner: root group: root - mode: '0755' + mode: '0644' # The unguarded ~/.bashrc PATH lines are deliberately NOT planted. The tasks # that remove them sit in the install path of the developer and diff --git a/ansible/molecule/remediation/verify.yml b/ansible/molecule/remediation/verify.yml index 0879571..282d8f5 100644 --- a/ansible/molecule/remediation/verify.yml +++ b/ansible/molecule/remediation/verify.yml @@ -277,6 +277,9 @@ - {path: .local/bin/kubeconform, gone: false} - {path: .local/bin/tinygo-dev, gone: false} - {path: .local/bin/argocd, gone: false} + - {path: .local/bin/dive, gone: false} + - {path: .local/bin/kubectx, gone: false} + - {path: .local/bin/yq, gone: false} loop_control: label: "{{ item.path }}" register: verify_user_copies @@ -284,11 +287,11 @@ - name: Assert user-level duplicates went and everything else stayed ansible.builtin.assert: that: - - item.stat.exists != item.item.gone + - (item.stat.exists or item.stat.islnk | default(false)) != item.item.gone fail_msg: >- ~/{{ item.item.path }} {{ 'survived beside the managed copy it shadows' - if item.item.gone else 'was REMOVED -- it was the only copy, a link to - the managed one, or a tool the roles do not manage' }}. + if item.item.gone else 'was REMOVED -- it was the only copy, a link, a + directory, or a tool the sweep does not cover' }}. success_msg: "~/{{ item.item.path }} {{ 'removed' if item.item.gone else 'left alone' }}" loop: "{{ verify_user_copies.results }}" loop_control: @@ -304,6 +307,7 @@ - /usr/local/bin/argocd - /usr/local/bin/macbash - /usr/local/bin/git-scrub + - /usr/local/bin/yq - /usr/local/go/bin/go register: verify_managed_copies @@ -317,36 +321,93 @@ loop_control: label: "{{ item.item }}" - - name: Re-read the installed packages (Ubuntu) + - name: Re-read the installed packages ansible.builtin.package_facts: manager: auto when: ansible_facts['distribution'] == 'Ubuntu' - # golang-1.26-go is whichever versioned package /usr/bin/go resolved to. - - name: Assert the hand-installed and distro duplicates were purged (Ubuntu) + - name: List the installed packages (Fedora) + ansible.builtin.command: + argv: [rpm, -qa, --qf, "%{NAME}\n"] + register: verify_rpms + changed_when: false + when: ansible_facts['distribution'] == 'Fedora' + + - name: Collect the installed package names + ansible.builtin.set_fact: + verify_installed: >- + {{ ansible_facts.packages.keys() | list if ansible_facts['distribution'] == 'Ubuntu' + else verify_rpms.stdout_lines }} + + # On the golink host /usr/local/go IS the distro tree, so the distro Go + # stays. On Fedora the repository golangci-lint requires golang, so it stays + # there too. + - name: Assert the hand-installed and distro duplicates were removed ansible.builtin.assert: that: - - ansible_facts.packages.keys() | select('match', item) | list | length == 0 + - verify_installed | select('match', item) | list | length == 0 fail_msg: "{{ item }} is still installed beside the managed copy it duplicates." - success_msg: "{{ item }} purged" - loop: - - '^macbash$' - - '^golang-go$' - - '^golang-src$' - - '^golang-[0-9.]+-go$' - when: ansible_facts['distribution'] == 'Ubuntu' + success_msg: "{{ item }} removed" + loop: >- + {{ ['^macbash$'] + + ([] if 'golink' in inventory_hostname else + (['^golang-go$', '^golang-src$', '^golang-[0-9.]+-go$'] + if ansible_facts['distribution'] == 'Ubuntu' else [])) }} - - name: Assert a duplicate package with a dependant was kept (Ubuntu) + - name: Assert the packages that must stay are still installed ansible.builtin.assert: that: - - "'git-scrub' in ansible_facts.packages" - - "'git-scrub-dependant' in ansible_facts.packages" + - item in verify_installed fail_msg: >- - The git-scrub package was removed although git-scrub-dependant needs it -- - removing it took a package nobody asked us to touch. - success_msg: "git-scrub kept for its dependant" + {{ item }} was removed. It has a dependant, is not on the purge list, + is offered by a repository, or is the only Go. + success_msg: "{{ item }} kept" + loop: >- + {{ ((['git-scrub', 'git-scrub-dependant', 'kind', 'dive'] + + (['golang-go'] if 'golink' in inventory_hostname else [])) + if ansible_facts['distribution'] == 'Ubuntu' + else ['golangci-lint', 'golang']) }} + + # The Go purge must not leave the compiler toolchain for the updater's + # unattended autoremove to take. + - name: Simulate the updater's autoremove (Ubuntu) + ansible.builtin.command: + argv: [apt-get, -s, autoremove] + register: verify_autoremove + changed_when: false when: ansible_facts['distribution'] == 'Ubuntu' + - name: Assert the Go purge orphaned no compiler toolchain (Ubuntu) + ansible.builtin.assert: + that: + - verify_autoremove.stdout_lines | select('match', '^Remv ' ~ item ~ ' ') | list | length == 0 + fail_msg: "apt-get autoremove would now remove {{ item }}, orphaned by the Go purge." + success_msg: "{{ item }} not autoremovable" + loop: + - gcc + - binutils + - libc6-dev + when: ansible_facts['distribution'] == 'Ubuntu' + + - name: List what dnf would autoremove (Fedora) + ansible.builtin.command: + argv: [dnf, -q, repoquery, --unneeded, --qf, "%{name}\n"] + register: verify_unneeded + changed_when: false + when: ansible_facts['distribution'] == 'Fedora' + + - name: Assert the Go purge orphaned no compiler toolchain (Fedora) + ansible.builtin.assert: + that: + - item not in verify_unneeded.stdout_lines + fail_msg: "dnf autoremove would now remove {{ item }}, orphaned by the Go purge." + success_msg: "{{ item }} not autoremovable" + loop: + - gcc + - binutils + - glibc-devel + when: ansible_facts['distribution'] == 'Fedora' + # PATH hygiene is NOT asserted here. The tasks that drop the unguarded # ~/.bashrc prepends live in the install path of the developer and # developer-rust roles, not under the `removals` tag this scenario diff --git a/ansible/roles/astral/tasks/main.yml b/ansible/roles/astral/tasks/main.yml index a7242be..80460a7 100644 --- a/ansible/roles/astral/tasks/main.yml +++ b/ansible/roles/astral/tasks/main.yml @@ -139,7 +139,7 @@ ansible.builtin.command: cmd: "uv tool install {{ item }}" environment: - PATH: "{{ user_home }}/.cargo/bin:{{ user_home }}/.local/bin:{{ ansible_facts['env'].PATH }}" + PATH: "{{ user_home }}/.local/bin:{{ ansible_facts['env'].PATH }}" become: true become_user: "{{ actual_user }}" loop: diff --git a/ansible/roles/contributor/tasks/hyperi_ci.yml b/ansible/roles/contributor/tasks/hyperi_ci.yml index e3c7440..fd9d52d 100644 --- a/ansible/roles/contributor/tasks/hyperi_ci.yml +++ b/ansible/roles/contributor/tasks/hyperi_ci.yml @@ -104,20 +104,24 @@ # # Not packaged anywhere, so cargo it is -- the bottom rung, and the only rung. # -# Into the effective CARGO_HOME developer-rust resolved, where `cargo -# install-update` finds it; ~/.cargo when that role is not in the run. +# Into the effective CARGO_HOME developer-rust resolved, else the one the host +# exports, where `cargo install-update` finds it. - name: Install alint (repository-structure linter, NOT ansible-lint) ansible.builtin.command: cmd: cargo install alint - creates: "{{ developer_rust_cargo_home | default(user_home ~ '/.cargo') }}/bin/alint" + creates: "{{ contributor_cargo_home }}/bin/alint" become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" # /opt/homebrew/opt/rustup/bin: brew's rustup never links its shims into # /opt/homebrew/bin, so without it no cargo resolves on a rustup Mac. This # task has no distribution gate, so it runs there too. environment: >- - {{ cargo_env | default({'PATH': user_home ~ '/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:/usr/local/bin:' - ~ ansible_facts['env'].PATH}) }} + {{ cargo_env | default({'PATH': contributor_cargo_home ~ '/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:/usr/local/bin:' + ~ ansible_facts['env'].PATH, + 'CARGO_HOME': contributor_cargo_home} + | combine({'RUSTUP_HOME': hyperi_host_rustup_home} if hyperi_host_rustup_home | default('') else {})) }} + vars: + contributor_cargo_home: "{{ developer_rust_cargo_home | default(hyperi_cargo_home | default(user_home ~ '/.cargo')) }}" register: contributor_alint changed_when: "'Installed package' in contributor_alint.stdout | default('')" failed_when: false diff --git a/ansible/roles/contributor/tasks/typos.yml b/ansible/roles/contributor/tasks/typos.yml index 22432a0..e7ef5df 100644 --- a/ansible/roles/contributor/tasks/typos.yml +++ b/ansible/roles/contributor/tasks/typos.yml @@ -14,16 +14,20 @@ environment: "{{ homebrew_env }}" when: ansible_facts['distribution'] == 'MacOSX' -# Into the effective CARGO_HOME developer-rust resolved, where `cargo -# install-update` finds it; ~/.cargo when that role is not in the run. +# Into the effective CARGO_HOME developer-rust resolved, else the one the host +# exports, where `cargo install-update` finds it. - name: Install typos (Linux — cargo) ansible.builtin.command: cmd: cargo install typos-cli - creates: "{{ developer_rust_cargo_home | default(user_home ~ '/.cargo') }}/bin/typos" + creates: "{{ contributor_cargo_home }}/bin/typos" become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" environment: >- - {{ cargo_env | default({'PATH': user_home ~ '/.cargo/bin:/usr/local/bin:' ~ ansible_facts['env'].PATH}) }} + {{ cargo_env | default({'PATH': contributor_cargo_home ~ '/bin:/usr/local/bin:' ~ ansible_facts['env'].PATH, + 'CARGO_HOME': contributor_cargo_home} + | combine({'RUSTUP_HOME': hyperi_host_rustup_home} if hyperi_host_rustup_home | default('') else {})) }} + vars: + contributor_cargo_home: "{{ developer_rust_cargo_home | default(hyperi_cargo_home | default(user_home ~ '/.cargo')) }}" register: contributor_typos changed_when: "'Installed package' in contributor_typos.stdout | default('')" failed_when: false diff --git a/ansible/roles/contributor/tasks/verify.yml b/ansible/roles/contributor/tasks/verify.yml index 6c7241d..bce8732 100644 --- a/ansible/roles/contributor/tasks/verify.yml +++ b/ansible/roles/contributor/tasks/verify.yml @@ -30,7 +30,7 @@ ansible.builtin.command: "{{ item }} --version" environment: PATH: >- - {{ developer_rust_cargo_home | default(user_home ~ '/.cargo') }}/bin:/opt/homebrew/bin:/usr/local/bin:{{ + {{ developer_rust_cargo_home | default(hyperi_cargo_home | default(user_home ~ '/.cargo')) }}/bin:/opt/homebrew/bin:/usr/local/bin:{{ user_home }}/.local/bin:{{ ansible_facts['env'].PATH }} loop: - hyperi-ci diff --git a/ansible/roles/developer-go/tasks/go.yml b/ansible/roles/developer-go/tasks/go.yml index 51b10ea..2e07b24 100644 --- a/ansible/roles/developer-go/tasks/go.yml +++ b/ansible/roles/developer-go/tasks/go.yml @@ -104,6 +104,28 @@ group: root mode: '0644' + # The profile drop-in reaches login shells only. Cron, systemd units and + # anything else on the default PATH find go through these links. + - name: Check for go and gofmt in /usr/local/bin + ansible.builtin.stat: + path: "/usr/local/bin/{{ item }}" + follow: false + loop: [go, gofmt] + register: developer_go_bin_links + + # A real file there is someone else's, so only a missing entry or a link + # is pointed at the toolchain. + - name: Link go and gofmt into /usr/local/bin + ansible.builtin.file: + src: "/usr/local/go/bin/{{ item.item }}" + dest: "/usr/local/bin/{{ item.item }}" + state: link + force: true + loop: "{{ developer_go_bin_links.results }}" + loop_control: + label: "{{ item.item }}" + when: not item.stat.exists or item.stat.islnk + - name: Install delve (Fedora) ansible.builtin.dnf: name: delve diff --git a/ansible/roles/developer-go/tasks/superseded.yml b/ansible/roles/developer-go/tasks/superseded.yml index c893648..1e8f9af 100644 --- a/ansible/roles/developer-go/tasks/superseded.yml +++ b/ansible/roles/developer-go/tasks/superseded.yml @@ -4,11 +4,11 @@ # systemd unit, an IDE launched outside a login shell -- so a build there gets # the distro's Go instead of the managed one. # -# Removed only once /usr/local/go/bin/go resolves, only where /usr/bin/go -# resolves outside /usr/local (a link someone pointed at the managed toolchain is -# not the distro's), and only when the removal touches nothing beyond the Go -# packages themselves: a package that needs the distro Go keeps it, and the run -# says which. +# Removed only when the managed Go is provably this role's and works: the +# profile drop-in go.yml writes exists, /usr/local/go/bin/go resolves under +# /usr/local to a different file than /usr/bin/go, and `go version` runs. A +# /usr/local/go linked into the distro tree is the distro Go, and it stays. +# Only packages named golang* are candidates, so gccgo is never touched. # # Linux only: Homebrew is the managed Go on macOS. @@ -23,11 +23,28 @@ failed_when: false check_mode: false +- name: Check that the managed Go runs + ansible.builtin.command: + argv: [/usr/local/go/bin/go, version] + register: developer_go_managed_version + changed_when: false + failed_when: false + check_mode: false + +- name: Check for the managed Go's profile drop-in + ansible.builtin.stat: + path: /etc/profile.d/hyperi-go.sh + register: developer_go_profile + - name: Find the packages behind the distro Go when: + - developer_go_profile.stat.exists + - developer_go_managed_version.rc == 0 - developer_go_resolved.results[0].rc == 0 - developer_go_resolved.results[1].rc == 0 + - developer_go_resolved.results[0].stdout.startswith('/usr/local/') - not developer_go_resolved.results[1].stdout.startswith('/usr/local/') + - developer_go_resolved.results[0].stdout != developer_go_resolved.results[1].stdout block: # The package that ships the resolved binary (golang-1.26-go on Ubuntu, # golang-bin on Fedora), plus the metapackages that pull it in. @@ -52,9 +69,11 @@ changed_when: false failed_when: false check_mode: false - when: developer_go_owner.rc == 0 + when: + - developer_go_owner.rc == 0 + - developer_go_owned is match('^golang') vars: - developer_go_owned: "{{ developer_go_owner.stdout_lines | first | regex_replace(':.*$', '') }}" + developer_go_owned: "{{ developer_go_owner.stdout_lines | first | default('') | regex_replace(':.*$', '') }}" # golang-1.26-go -> golang-1.26-src beside it; Fedora splits bin and src. developer_go_candidates: >- {{ (['golang', 'golang-go', 'golang-src', 'golang-doc', developer_go_owned, @@ -68,58 +87,20 @@ - name: Collect the distro Go packages to remove ansible.builtin.set_fact: developer_go_distro_pkgs: >- - {{ (developer_go_installed.stdout_lines | default([]) | select('match', '^ii ') - | map('regex_replace', '^ii +', '') | list) - if ansible_facts['distribution'] == 'Ubuntu' - else (developer_go_installed.stdout_lines | default([]) | reject('search', ' ') | list) }} - - - name: Simulate removing the distro Go - ansible.builtin.command: - argv: >- - {{ (['apt-get', '-s', 'purge'] if ansible_facts['distribution'] == 'Ubuntu' - else ['rpm', '-e', '--test']) + developer_go_distro_pkgs }} - register: developer_go_sim - changed_when: false - failed_when: false - check_mode: false - when: developer_go_distro_pkgs | length > 0 + {{ ((developer_go_installed.stdout_lines | default([]) | select('match', '^ii ') + | map('regex_replace', '^ii +', '') | list) + if ansible_facts['distribution'] == 'Ubuntu' + else (developer_go_installed.stdout_lines | default([]) | reject('search', ' ') | list)) + | select('match', '^golang') | list }} - - name: Note what removing the distro Go would also take - ansible.builtin.set_fact: - developer_go_sim_also: >- - {{ developer_go_sim.stdout_lines | default([]) | select('match', '^(Purg|Remv) ') - | map('regex_replace', '^\S+ (\S+).*$', '\1') - | reject('in', developer_go_distro_pkgs) | list }} + - name: Remove the distro Go superseded by /usr/local/go + ansible.builtin.include_role: + name: system_cleanup + tasks_from: purge_packages.yml + # The include is already gated by this file's tags, and its tasks carry none. + apply: + tags: ['always'] + vars: + system_cleanup_purge_packages: "{{ developer_go_distro_pkgs }}" + system_cleanup_purge_reason: the distro Go superseded by /usr/local/go when: developer_go_distro_pkgs | length > 0 - - - name: Purge the distro Go superseded by /usr/local/go (Ubuntu) - ansible.builtin.apt: - name: "{{ developer_go_distro_pkgs }}" - state: absent - purge: true - autoremove: false - when: - - ansible_facts['distribution'] == 'Ubuntu' - - developer_go_distro_pkgs | length > 0 - - developer_go_sim.rc == 0 - - developer_go_sim_also | length == 0 - - - name: Remove the distro Go superseded by /usr/local/go (Fedora) - ansible.builtin.dnf: - name: "{{ developer_go_distro_pkgs }}" - state: absent - autoremove: false - when: - - ansible_facts['distribution'] == 'Fedora' - - developer_go_distro_pkgs | length > 0 - - developer_go_sim.rc == 0 - - - name: Report a distro Go kept because something depends on it - ansible.builtin.debug: - msg: >- - Kept the distro Go ({{ developer_go_distro_pkgs | join(', ') }}) beside - /usr/local/go: removing it would also remove - {{ developer_go_sim_also | join(', ') if developer_go_sim_also else developer_go_sim.stderr | trim }}. - when: - - developer_go_distro_pkgs | length > 0 - - developer_go_sim.rc != 0 or developer_go_sim_also | length > 0 diff --git a/ansible/roles/developer-python/tasks/main.yml b/ansible/roles/developer-python/tasks/main.yml index 11a67a2..db7928b 100644 --- a/ansible/roles/developer-python/tasks/main.yml +++ b/ansible/roles/developer-python/tasks/main.yml @@ -25,7 +25,7 @@ ansible.builtin.command: cmd: uv tool install mypy environment: - PATH: "{{ user_home }}/.cargo/bin:{{ user_home }}/.local/bin:{{ ansible_facts['env'].PATH }}" + PATH: "{{ user_home }}/.local/bin:{{ ansible_facts['env'].PATH }}" become: true become_user: "{{ actual_user }}" register: devpy_mypy_install diff --git a/ansible/roles/developer-rust/README.md b/ansible/roles/developer-rust/README.md index 77e1f04..c4a1666 100644 --- a/ansible/roles/developer-rust/README.md +++ b/ansible/roles/developer-rust/README.md @@ -166,15 +166,17 @@ This does not reopen the objection in the SSoT note below: crates.io stays out of the global `rustc-wrapper` path. The binary is the project's own release artefact, digest-checked, not an unpinned `cargo install`. -**A cargo-installed sccache would shadow it.** `~/.cargo/bin` precedes `/usr/local/bin` on PATH, so anything typed by hand reaches the cargo copy while builds keep using the absolute path in the cargo config. That split is what makes a failed `--show-stats` look like a dead cache when every build is being cached normally. The role deregisters and removes the cargo copy once the managed one is in place (`tasks/strays.yml`). The setup tool run on its own only prints the `cargo uninstall` line. +**A cargo-installed sccache would shadow it.** The cargo bin directory precedes `/usr/local/bin` on PATH, so anything typed by hand reaches the cargo copy while builds keep using the absolute path in the cargo config. That split is what makes a failed `--show-stats` look like a dead cache when every build is being cached normally. On Linux the role deregisters and removes the cargo copy once the managed one is in place (`tasks/strays.yml`). The setup tool run on its own only prints the `cargo uninstall` line. -**Strays.** After the installs, the role clears what would otherwise run instead of the managed copies, in the effective `CARGO_HOME` and, where `CARGO_HOME` is relocated, in the old `~/.cargo`: +**Strays.** The role clears what would otherwise run instead of the managed copies: -- cargo-home copies of sccache, sd, fnm, uv and uvx, once the managed copy exists and is a different file +- cargo-home copies of sccache, sd, fnm, uv and uvx, once the managed copy exists and is a different file (Linux only) - the retired cargo-tarpaulin -- binaries in a superseded `~/.cargo/bin` that the effective home also holds (its registry and git caches, install record and anything installed only there stay) +- where `CARGO_HOME` is relocated, binaries in the old `~/.cargo/bin` that the effective home also holds -A cargo-installed copy goes through `cargo uninstall --root`, so `cargo install-update` does not reinstall it. +A cargo-installed copy goes through `cargo uninstall --root`, so `cargo install-update` does not reinstall it, and a `~/.cargo` package whose binaries all go is deregistered there too. The registry and git caches and anything installed only in `~/.cargo` stay on disk. They drop off PATH once the relocation is in effect, because the shell profile and the SOE PATH drop-in name `${CARGO_HOME:-$HOME/.cargo}/bin`. + +`~/.cargo` counts as superseded only when the host itself exports the relocated `CARGO_HOME` (in `/etc/environment` or a login profile). A home set only with `-e rust_cargo_home=...` leaves `~/.cargo` and its `config.toml` alone and records a warning, because the host's own cargo may still be using them. `build.build-dir` is stable from Rust 1.91. On an older toolchain the setup tool says so and leaves the per-project layout alone, so the default stays safe. diff --git a/ansible/roles/developer-rust/tasks/rust.yml b/ansible/roles/developer-rust/tasks/rust.yml index 7a00ce3..8fee664 100644 --- a/ansible/roles/developer-rust/tasks/rust.yml +++ b/ansible/roles/developer-rust/tasks/rust.yml @@ -30,63 +30,17 @@ # such a host produces a file the tool silently ignores while a stale one stays # in effect, which is a failure mode with no error message anywhere in it. # -# Ask the target user's own login shell, because that is the environment the -# developer's cargo runs in: it sees /etc/environment (via pam_env under -# become), /etc/profile.d and ~/.profile, which is where a relocation is -# declared. Ansible's `ansible_facts['env']` is the CONNECTION user's -# environment -- root under become -- so it cannot answer this. -# -# `${CARGO_HOME:-$HOME/.cargo}` is resolved by that shell, so the fallback uses -# the user's real home and matches hyperi-rust-govern's own resolution. -# -# The sentinel is not decoration: a login shell runs /etc/profile.d and -# ~/.profile first, and anything they print lands on stdout ahead of the answer. -# Selecting on a marker rather than on line 0 is what stops a login banner -# becoming CARGO_HOME. -# One task for every platform. Linux runs it under become as the target user; -# macOS as the connecting user, who is the target there. `timeout` is Linux -# coreutils: a profile that blocks would otherwise hang the converge, and -# `failed_when: false` offers no protection against a hang. -# -# Two sentinel lines rather than one line split on whitespace, so a path with a -# space in it resolves whole instead of silently as its first word. `last`, -# not `first`: the profile runs before the printf, so ours is always last, and -# a profile that prints the sentinel itself cannot win. -- name: Probe the target user's cargo environment - ansible.builtin.command: - cmd: >- - {{ 'zsh' if ansible_facts['distribution'] == 'MacOSX' else 'timeout 30 bash' }} - -lc 'printf "HYPERI_CARGO_HOME %s\nHYPERI_RUSTUP_HOME %s\n" "${CARGO_HOME:-$HOME/.cargo}" "${RUSTUP_HOME:-$HOME/.rustup}"' - become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" - become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" - register: developer_rust_env_probe - changed_when: false - failed_when: false - # Must run in check mode too: the facts below are undefined without it, and - # every later task then templates an empty path. - check_mode: false - tags: ['rust-cache', 'rust-governor', 'rust-llvm'] - -# An explicit role variable, else what the host said, else the upstream default -# so nothing downstream is ever templated blank. Trailing slashes are stripped -# because the retire task below compares this against ~/.cargo as a string, and -# `~/.cargo/` would read as a relocation and rename the live config. +# An explicit role variable, else what the target user's login environment +# exports (probed in the developer role's init.yml), else the upstream default, +# so nothing downstream is ever templated blank. - name: Resolve the effective cargo and rustup homes ansible.builtin.set_fact: developer_rust_cargo_home: >- - {{ rust_cargo_home | default(_probed_cargo, true) + {{ rust_cargo_home | default(hyperi_host_cargo_home | default(''), true) | default(user_home ~ '/.cargo', true) | regex_replace('/+$', '') }} developer_rust_rustup_home: >- - {{ rust_rustup_home | default(_probed_rustup, true) + {{ rust_rustup_home | default(hyperi_host_rustup_home | default(''), true) | default(user_home ~ '/.rustup', true) | regex_replace('/+$', '') }} - vars: - _lines: "{{ developer_rust_env_probe.stdout_lines | default([], true) }}" - _probed_cargo: >- - {{ _lines | select('match', '^HYPERI_CARGO_HOME ') | list | last - | default('') | regex_replace('^\S+ ', '') }} - _probed_rustup: >- - {{ _lines | select('match', '^HYPERI_RUSTUP_HOME ') | list | last - | default('') | regex_replace('^\S+ ', '') }} tags: ['rust-cache', 'rust-governor', 'rust-llvm'] # Absolute, and free of the two characters that would break the root-owned @@ -108,7 +62,7 @@ # Compared by device and inode, not by path: ~/.cargo can be a symlink to the # relocated home, and then it is the live home, not a stale one. -- name: Identify the default and the effective cargo homes +- name: Identify the default, the effective and the host-exported cargo homes ansible.builtin.stat: path: "{{ item }}" follow: true @@ -116,21 +70,44 @@ loop: - "{{ user_home }}/.cargo" - "{{ developer_rust_cargo_home }}" + - "{{ hyperi_host_cargo_home | default('', true) or '/nonexistent/hyperi-no-cargo-home' }}" become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" register: developer_rust_cargo_home_dirs tags: ['rust-cache', 'rust-governor', 'rust-llvm'] -# True when ~/.cargo is a separate directory from the effective CARGO_HOME: -# whatever it still holds is left over from before the relocation. +# ~/.cargo is stale only when it is a separate directory from the effective +# home AND the host itself exports that effective home: cargo, hyperi-update and +# every shell then use it, and nothing still reads ~/.cargo. A home set only by +# rust_cargo_home is not proof -- the host's own cargo may still be ~/.cargo. - name: Note whether ~/.cargo is a stale home beside a relocated one ansible.builtin.set_fact: - developer_rust_cargo_home_stale: >- - {{ _default.exists and _default.isdir - and not (_effective.exists and _effective.dev == _default.dev and _effective.inode == _default.inode) }} + developer_rust_cargo_home_relocated: "{{ _separate | bool }}" + developer_rust_cargo_home_stale: "{{ _separate | bool and _declared | bool }}" vars: _default: "{{ developer_rust_cargo_home_dirs.results[0].stat }}" _effective: "{{ developer_rust_cargo_home_dirs.results[1].stat }}" + _host: "{{ developer_rust_cargo_home_dirs.results[2].stat }}" + _separate: >- + {{ _default.exists and _default.isdir + and not (_effective.exists and _effective.dev == _default.dev and _effective.inode == _default.inode) }} + _declared: >- + {{ _effective.exists and _host.exists + and _effective.dev == _host.dev and _effective.inode == _host.inode }} + tags: ['rust-cache', 'rust-governor', 'rust-llvm'] + +- name: Record that ~/.cargo was left alone beside an undeclared CARGO_HOME + # noqa: var-naming[no-role-prefix] -- deploy_warnings is the shared accumulator + ansible.builtin.set_fact: + deploy_warnings: >- + {{ deploy_warnings | default([]) + + ['cargo home: the role used ' ~ developer_rust_cargo_home ~ ' but the login environment of ' + ~ actual_user ~ ' exports ' ~ (hyperi_host_cargo_home | default('', true) or 'no CARGO_HOME') + ~ ', so ' ~ user_home ~ '/.cargo was neither swept nor had its config.toml retired.' + ~ ' Declare CARGO_HOME on the host (for example in /etc/environment) and re-run.'] }} + when: + - developer_rust_cargo_home_relocated | bool + - not developer_rust_cargo_home_stale | bool tags: ['rust-cache', 'rust-governor', 'rust-llvm'] - name: Report where the toolchain was resolved to @@ -806,6 +783,18 @@ become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" +# Before `cargo install-update`, which would otherwise rebuild a retired or +# duplicate tool first, and after the installs, so every effective-home tool +# exists before its ~/.cargo copy goes. +- name: Remove cargo-home strays + ansible.builtin.include_tasks: + file: strays.yml + apply: + tags: ['developer-rust', 'rust'] + vars: + developer_rust_strays_pass: tools + tags: ['developer-rust', 'rust'] + # The installs above are guarded by `creates:`, so on a box that already has a # tool they never look at its version. This is what carries them forward, and # it is why re-running the role upgrades rather than only installing. @@ -914,17 +903,15 @@ when: developer_rust_setup.rc | default(0) != 0 tags: ['rust-cache'] -# ============================================================ -# Strays: duplicates, retired tools, a superseded ~/.cargo -# ============================================================ -# After every install above and after hyperi-rust-setup, so each managed copy -# is in place before the copy it supersedes goes, and the cargo config already -# names the managed sccache rather than one about to be removed. -- name: Remove cargo-home strays +# After hyperi-rust-setup, so the managed sccache is in place and the cargo +# config already names it rather than the copy about to be removed. +- name: Remove cargo-home copies of sccache ansible.builtin.include_tasks: file: strays.yml apply: tags: ['developer-rust', 'rust'] + vars: + developer_rust_strays_pass: sccache tags: ['developer-rust', 'rust'] # ============================================================ diff --git a/ansible/roles/developer-rust/tasks/strays.yml b/ansible/roles/developer-rust/tasks/strays.yml index a0e41aa..0355bf4 100644 --- a/ansible/roles/developer-rust/tasks/strays.yml +++ b/ansible/roles/developer-rust/tasks/strays.yml @@ -8,9 +8,13 @@ # next upstream release. The file itself goes afterwards for a binary cargo has # no record of, which is what an installer script or a copied directory leaves. # -# A copy goes only when the copy it duplicates exists and is a different file -# (device and inode after following links), so the only working copy of a tool -# is never the one removed. +# A copy goes only when the copy it duplicates exists and is a different regular +# file (device and inode after following links), so the only working copy of a +# tool is never the one removed. +# +# Included twice from rust.yml: the `tools` pass runs before `cargo +# install-update`, so nothing it deregisters is rebuilt first. The `sccache` +# pass runs after hyperi-rust-setup has put the managed sccache in place. - name: Name the cargo homes to sweep ansible.builtin.set_fact: @@ -28,25 +32,27 @@ - name: Remove cargo-home copies of managed tools (Linux) when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] vars: - developer_rust_managed_dupes: - - {bin: sccache, crate: sccache, managed: [/usr/local/bin/sccache]} - - {bin: sd, crate: sd, managed: [/usr/local/bin/sd, /usr/bin/sd]} - - {bin: fnm, crate: fnm, managed: [/usr/local/bin/fnm]} - - {bin: uv, managed: ["{{ user_home }}/.local/bin/uv", /usr/bin/uv]} - - {bin: uvx, managed: ["{{ user_home }}/.local/bin/uvx", /usr/bin/uvx]} + developer_rust_managed_dupes: >- + {{ [{'bin': 'sccache', 'crate': 'sccache', 'managed': ['/usr/local/bin/sccache']}] + if developer_rust_strays_pass == 'sccache' else + [{'bin': 'sd', 'crate': 'sd', 'managed': ['/usr/local/bin/sd', '/usr/bin/sd']}, + {'bin': 'fnm', 'crate': 'fnm', 'managed': ['/usr/local/bin/fnm']}, + {'bin': 'uv', 'managed': [user_home ~ '/.local/bin/uv', '/usr/bin/uv']}, + {'bin': 'uvx', 'managed': [user_home ~ '/.local/bin/uvx', '/usr/bin/uvx']}] }} developer_rust_dupe_paths: >- {{ (developer_rust_managed_dupes | map(attribute='managed') | flatten) + (developer_rust_swept_homes | map('regex_replace', '$', '/bin') | product(developer_rust_managed_dupes | map(attribute='bin')) | map('join', '/') | list) }} - # path -> "device:inode" for every candidate that resolves to a file. + # path -> "device:inode" for every candidate that is a regular file after + # following links. Directories and dangling links drop out. developer_rust_dupe_ids: >- {{ dict(developer_rust_dupe_stat.stdout_lines | default([]) | map('regex_replace', '^\S+ ', '') | zip(developer_rust_dupe_stat.stdout_lines | default([]) | map('regex_replace', ' .*$', ''))) }} block: - # Missing paths make stat exit non-zero and drop out of the output. + # Missing paths make find exit non-zero and drop out of the output. - name: Identify the managed tools and their cargo-home copies ansible.builtin.command: - argv: "{{ ['stat', '-L', '--printf', '%d:%i %n\\n', '--'] + developer_rust_dupe_paths }}" + argv: "{{ ['find', '-L'] + developer_rust_dupe_paths + ['-maxdepth', '0', '-type', 'f', '-printf', '%D:%i %p\\n'] }}" become: true become_user: "{{ actual_user }}" register: developer_rust_dupe_stat @@ -65,8 +71,10 @@ label: "{{ item.0 }}/bin/{{ item.1.bin }}" register: developer_rust_dupe_uninstall changed_when: "'Removing' in developer_rust_dupe_uninstall.stderr | default('')" - # Not registered with cargo: the file removal below takes it. - failed_when: false + # A binary cargo has no record of is left to the file removal below. + failed_when: >- + developer_rust_dupe_uninstall.rc != 0 + and 'did not match any packages' not in developer_rust_dupe_uninstall.stderr | default('') when: - item.1.crate is defined - developer_rust_dupe_path in developer_rust_dupe_ids @@ -112,8 +120,11 @@ loop: "{{ developer_rust_swept_homes }}" register: developer_rust_tarpaulin_uninstall changed_when: "'Removing' in developer_rust_tarpaulin_uninstall.stderr | default('')" - # Not registered with cargo: the file removal below takes it. - failed_when: false + # A binary cargo has no record of is left to the file removal below. + failed_when: >- + developer_rust_tarpaulin_uninstall.rc | default(0) != 0 + and 'did not match any packages' not in developer_rust_tarpaulin_uninstall.stderr | default('') + when: developer_rust_strays_pass == 'tools' - name: Remove the superseded cargo-tarpaulin ansible.builtin.file: @@ -122,6 +133,7 @@ become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" loop: "{{ developer_rust_swept_homes }}" + when: developer_rust_strays_pass == 'tools' # ============================================================ # What a relocated CARGO_HOME left in ~/.cargo @@ -129,18 +141,34 @@ # ~/.cargo/bin stays on PATH wherever a profile still names it, so a tool left # there runs instead of the copy in the effective home, which is the one the # installs and `cargo install-update` keep current. Only a binary the effective -# home also holds goes. The registry and git caches, the install record and -# anything installed only there stay: they may be the developer's own. +# home also holds goes, and a package whose binaries all go is deregistered from +# ~/.cargo's install record too. The registry and git caches and anything +# installed only there stay: they may be the developer's own. - name: Remove binaries the effective cargo home supersedes - when: developer_rust_cargo_home_stale | default(false) | bool + when: + - developer_rust_strays_pass == 'tools' + - developer_rust_cargo_home_stale | default(false) | bool + vars: + developer_rust_stale_names: >- + {{ (developer_rust_stale_files.files + developer_rust_stale_links.files) + | map(attribute='path') | map('basename') | unique | sort }} block: - - name: List the binaries left in ~/.cargo/bin + # Regular files and links only: a directory named like a tool is not one. + - name: List the files left in ~/.cargo/bin ansible.builtin.find: paths: "{{ user_home }}/.cargo/bin" - file_type: any + file_type: file become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" - register: developer_rust_stale_bins + register: developer_rust_stale_files + + - name: List the links left in ~/.cargo/bin + ansible.builtin.find: + paths: "{{ user_home }}/.cargo/bin" + file_type: link + become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" + register: developer_rust_stale_links # find reports a link's own inode, so both sides are stat'd through links. - name: Identify each ~/.cargo/bin binary and its effective-home namesake @@ -151,24 +179,66 @@ become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" loop: >- - {{ developer_rust_stale_bins.files | map(attribute='path') | sort - | map('regex_replace', '^.*/', developer_rust_cargo_home ~ '/bin/') | list - + (developer_rust_stale_bins.files | map(attribute='path') | sort | list) }} + {{ developer_rust_stale_names | map('regex_replace', '^', developer_rust_cargo_home ~ '/bin/') | list + + (developer_rust_stale_names | map('regex_replace', '^', user_home ~ '/.cargo/bin/') | list) }} register: developer_rust_bin_pairs - - name: Remove ~/.cargo/bin binaries the effective home also holds - ansible.builtin.file: - path: "{{ item.1.item }}" - state: absent + - name: Start the list of superseded ~/.cargo/bin binaries + ansible.builtin.set_fact: + developer_rust_superseded: [] + + # Results come effective-home namesake first, then the ~/.cargo copy, so + # item N pairs with item N plus the name count. + - name: Collect the ~/.cargo/bin binaries the effective home supersedes + ansible.builtin.set_fact: + developer_rust_superseded: "{{ developer_rust_superseded + [item.1.item | basename] }}" + loop: >- + {{ developer_rust_bin_pairs.results[:developer_rust_stale_names | length] + | zip(developer_rust_bin_pairs.results[developer_rust_stale_names | length:]) | list }} + loop_control: + label: "{{ item.1.item | basename }}" + when: + - item.0.stat.isreg | default(false) + - item.1.stat.isreg | default(false) + - item.0.stat.dev != item.1.stat.dev or item.0.stat.inode != item.1.stat.inode + + - name: List the packages ~/.cargo's install record holds + ansible.builtin.command: + argv: [cargo, install, --list, --root, "{{ user_home }}/.cargo"] + environment: "{{ cargo_env }}" + become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" + register: developer_rust_stale_record + changed_when: false + failed_when: false + check_mode: false + + # `cargo install --list` prints `name vX.Y.Z:` then one indented line per + # binary. Each match is [name, the block of binary lines]. + - name: Deregister ~/.cargo packages whose binaries are all superseded + ansible.builtin.command: + argv: [cargo, uninstall, --root, "{{ user_home }}/.cargo", "{{ item.0 }}"] + environment: "{{ cargo_env }}" become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" - # First half of the stat results is the effective home, second half the stale one. loop: >- - {{ developer_rust_bin_pairs.results[:developer_rust_stale_bins.matched] - | zip(developer_rust_bin_pairs.results[developer_rust_stale_bins.matched:]) | list }} + {{ developer_rust_stale_record.stdout | default('') + | regex_findall('(?m)^(\S+) v[^\n]*:\n((?:[ \t]+\S+(?:\n|\Z))*)') }} loop_control: - label: "{{ item.1.item }}" + label: "{{ item.0 }}" + register: developer_rust_stale_uninstall + changed_when: "'Removing' in developer_rust_stale_uninstall.stderr | default('')" + failed_when: >- + developer_rust_stale_uninstall.rc != 0 + and 'did not match any packages' not in developer_rust_stale_uninstall.stderr | default('') when: - - item.0.stat.exists - - item.1.stat.exists - - item.0.stat.dev != item.1.stat.dev or item.0.stat.inode != item.1.stat.inode + - item.1.split() | length > 0 + - item.1.split() | difference(developer_rust_superseded) | length == 0 + + - name: Remove ~/.cargo/bin binaries the effective home also holds + ansible.builtin.file: + path: "{{ user_home }}/.cargo/bin/{{ item }}" + state: absent + become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" + loop: "{{ developer_rust_superseded }}" diff --git a/ansible/roles/developer/files/update/hyperi-update-linux.sh b/ansible/roles/developer/files/update/hyperi-update-linux.sh index 2de49f3..a8b3c5b 100644 --- a/ansible/roles/developer/files/update/hyperi-update-linux.sh +++ b/ansible/roles/developer/files/update/hyperi-update-linux.sh @@ -34,10 +34,23 @@ set -uo pipefail +# A GUI launch or a timer does not run the login shell, so a CARGO_HOME or +# RUSTUP_HOME declared there is missing here. Ask the login shell for it, or +# every cargo step below would act on ~/.cargo instead of the real home. +for var in CARGO_HOME RUSTUP_HOME; do + if [[ -z "${!var:-}" ]]; then + val="$(timeout 30 bash -lc "printenv $var" 2>/dev/null | tail -n 1)" + [[ -n "$val" ]] && export "$var=$val" + fi +done +unset var val +CARGO_BIN="${CARGO_HOME:-$HOME/.cargo}/bin" + # Make user-level tools reachable even when launched from a GUI/.desktop entry # that doesn't source the login shell (rustup and the cargo tools live in the -# cargo home, uv and claude in ~/.local/bin, Go in /usr/local/go/bin). -export PATH="$HOME/.local/bin:${CARGO_HOME:-$HOME/.cargo}/bin:/usr/local/go/bin:$PATH" +# cargo home, uv and claude in ~/.local/bin, Go in /usr/local/go/bin and the +# go-installed tools in ~/go/bin). +export PATH="$HOME/.local/bin:$CARGO_BIN:/usr/local/go/bin:$HOME/go/bin:$PATH" ASSUME_YES=0 @@ -257,8 +270,12 @@ fi # Updates the Rust toolchains. NOTE: cargo-installed binaries (nextest, deny, # bacon, ...) are not refreshed by rustup; reinstall them with cargo if needed. section "rustup toolchains" +# A cargo home with no rustup means the toolchain is not where the +# environment says it is, which is a fault to report, not a tool to skip. if have rustup; then run "rustup update" rustup update +elif [[ -d "$CARGO_BIN" ]]; then + fail "rustup not found in $CARGO_BIN" else skip "rustup not found" fi @@ -273,6 +290,8 @@ fi section "cargo tools" if have cargo-install-update; then run "cargo install-update -a --locked" cargo install-update -a --locked +elif [[ -d "$CARGO_BIN" ]]; then + fail "cargo-install-update not found in $CARGO_BIN (cargo tools are not being updated)" else skip "cargo-install-update not found (install the cargo-update crate)" fi @@ -397,11 +416,19 @@ is_elf() { # -> 0 if it begins with the ELF magic (7f 45 4c 46) [[ "$(head -c 4 "$1" 2>/dev/null | od -An -tx1 | tr -d ' \n')" == "7f454c46" ]] } +# installed_local -> 0 if /usr/local/bin/ is a file under +# /usr/local. A copy found elsewhere on PATH (~/.local/bin, a package) is not +# the one these helpers manage, and refetching for it would add a second copy. +installed_local() { + local real + real="$(readlink -e "/usr/local/bin/$1" 2>/dev/null)" && [[ -f "$real" && "$real" == /usr/local/* ]] +} + # refetch_raw -- a bare executable asset. # Template may use {TAG} and {ARCH}. refetch_raw() { local name="$1" repo="$2" tmpl="$3" tag asset url tmp - have "$name" || { skip "$name not installed"; return; } + installed_local "$name" || { skip "$name not installed in /usr/local/bin"; return; } tag="$(gh_latest_tag "$repo")" [[ -n "$tag" ]] || { FAILURES+=("$name (no release tag)"); return; } asset="${tmpl//\{TAG\}/$tag}"; asset="${asset//\{ARCH\}/$ARCH_DEB}" @@ -421,7 +448,7 @@ refetch_raw() { # leading v) and {ARCH}. refetch_targz() { local name="$1" repo="$2" tmpl="$3" member="$4" tag ver asset url tmp dir - have "$name" || { skip "$name not installed"; return; } + installed_local "$name" || { skip "$name not installed in /usr/local/bin"; return; } tag="$(gh_latest_tag "$repo")" [[ -n "$tag" ]] || { FAILURES+=("$name (no release tag)"); return; } ver="${tag#v}" @@ -442,7 +469,7 @@ refetch_targz() { # the binary sits one directory deep in the tarball. refetch_targz_nested() { local name="$1" repo="$2" tmpl="$3" tag ver asset url tmp dir - have "$name" || { skip "$name not installed"; return; } + installed_local "$name" || { skip "$name not installed in /usr/local/bin"; return; } tag="$(gh_latest_tag "$repo")" [[ -n "$tag" ]] || { FAILURES+=("$name (no release tag)"); return; } ver="${tag#v}" @@ -463,7 +490,7 @@ refetch_targz_nested() { # kustomize is a monorepo: /releases/latest can point at a non-CLI component, so # pull the newest kustomize CLI asset URL straight from the releases list. refetch_kustomize() { - have kustomize || { skip "kustomize not installed"; return; } + installed_local kustomize || { skip "kustomize not installed in /usr/local/bin"; return; } local url tmp dir url="$(curl -fsSL "https://api.github.com/repos/kubernetes-sigs/kustomize/releases" 2>/dev/null \ | grep -oE "https://[^\"]*/kustomize_v[0-9.]+_linux_${ARCH_DEB}\.tar\.gz" | head -1)" diff --git a/ansible/roles/developer/files/update/hyperi-update-macos.sh b/ansible/roles/developer/files/update/hyperi-update-macos.sh index efda187..d06f4c1 100644 --- a/ansible/roles/developer/files/update/hyperi-update-macos.sh +++ b/ansible/roles/developer/files/update/hyperi-update-macos.sh @@ -41,7 +41,7 @@ emulate -L zsh # Make user-level tools reachable even when launched from the GUI app or a # non-login shell (Ansible): brew lives outside the base PATH on both Apple # silicon and Intel. -export PATH="$HOME/.local/bin:${CARGO_HOME:-$HOME/.cargo}/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" +export PATH="$HOME/.local/bin:${CARGO_HOME:-$HOME/.cargo}/bin:$HOME/go/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" ASSUME_YES=0 diff --git a/ansible/roles/developer/tasks/init.yml b/ansible/roles/developer/tasks/init.yml index fe648b5..c183ab5 100644 --- a/ansible/roles/developer/tasks/init.yml +++ b/ansible/roles/developer/tasks/init.yml @@ -68,6 +68,52 @@ ansible.builtin.debug: msg: "Installing for user: {{ actual_user }} (home: {{ user_home }})" +# Where the target user's own login environment puts cargo and rustup, read +# here so every role that installs or sweeps cargo tools agrees on it. +# +# The target user's login shell sees /etc/environment (pam_env under become), +# /etc/profile.d and ~/.profile, which is where a relocation is declared. +# `ansible_facts['env']` is the connecting user's environment and cannot see them. +# `printenv` reports only what is exported, which is what cargo, hyperi-update +# and a systemd unit actually inherit -- an unexported shell variable is not. +# +# Sentinel lines, and `last`, because a profile can print ahead of the answer. +# `timeout` because a blocking profile would otherwise hang the converge. +- name: Probe the target user's exported cargo and rustup homes + ansible.builtin.command: + cmd: >- + {{ 'zsh' if ansible_facts['distribution'] == 'MacOSX' else 'timeout 30 bash' }} + -lc 'printf "HYPERI_CARGO_HOME %s\nHYPERI_RUSTUP_HOME %s\n" "$(printenv CARGO_HOME)" "$(printenv RUSTUP_HOME)"' + become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" + register: developer_env_probe + changed_when: false + failed_when: false + check_mode: false + +# Empty when the host exports nothing. Trailing slashes are stripped so the +# value compares cleanly with paths built from it. +- name: Record the target user's exported cargo and rustup homes + # noqa: var-naming[no-role-prefix] -- consumed by developer-rust and + # contributor, so it is deliberately play-scoped, not role-scoped. + ansible.builtin.set_fact: + hyperi_host_cargo_home: >- + {{ developer_env_probe_lines | select('match', '^HYPERI_CARGO_HOME ') | list | last + | default('') | regex_replace('^\S+ ?', '') | regex_replace('/+$', '') }} + hyperi_host_rustup_home: >- + {{ developer_env_probe_lines | select('match', '^HYPERI_RUSTUP_HOME ') | list | last + | default('') | regex_replace('^\S+ ?', '') | regex_replace('/+$', '') }} + vars: + developer_env_probe_lines: "{{ developer_env_probe.stdout_lines | default([], true) }}" + +# The cargo home a role that installs cargo tools uses when developer-rust is +# not in the run to resolve its own. +- name: Record the cargo home the host's cargo uses + # noqa: var-naming[no-role-prefix] -- consumed by contributor, so it is + # deliberately play-scoped, not role-scoped. + ansible.builtin.set_fact: + hyperi_cargo_home: "{{ hyperi_host_cargo_home or (user_home ~ '/.cargo') }}" + # Here rather than beside the pnpm install because tag selection can reach a # consumer without its producer -- `--tags typescript` picks the TypeScript task # and not the Node one. This file carries `tags: ['always']`. diff --git a/ansible/roles/developer/tasks/removals.yml b/ansible/roles/developer/tasks/removals.yml index 7f87d4d..a49ff89 100644 --- a/ansible/roles/developer/tasks/removals.yml +++ b/ansible/roles/developer/tasks/removals.yml @@ -15,6 +15,10 @@ # would delete their work. This is a denylist of what WE removed, never an # allowlist of what is permitted. # +# The one exception is a second copy of a tool the roles DO manage: the +# user-level and hand-installed duplicates below go, because whichever copy +# wins on PATH, only the managed one is ever updated. +# # RULES FOR ADDING HERE # - When you drop an install, ADD the removal. Same commit. # - Say WHY it was dropped, so a future reader can retire the tombstone. @@ -79,73 +83,103 @@ # Hand-installed copies of tools the roles install system-wide. ~/.local/bin and # ~/go/bin precede /usr/local/bin and /usr/bin on PATH, so a copy left there -# shadows the managed install and no update ever reaches it. +# shadows the managed install and no update ever reaches it. A deliberate pin +# of a managed tool there (an older kubectl, golangci-lint v1) goes too. # -# A copy goes only when the system copy exists and is a different file (device -# and inode after following links): a user-level link to the managed binary is -# not a duplicate, and with no system copy the user's is the only one. Only the -# user-level path is removed, never what a link points at. Cargo homes are left -# to developer-rust, which knows the effective CARGO_HOME and deregisters a -# cargo-installed copy so `cargo install-update` does not bring it back. +# A copy goes only when it and the system copy are both regular files after +# following links and are different files (device and inode): a user-level +# link to the managed binary is not a duplicate, and with no system copy the +# user's is the only one. Only the user-level path is removed, never what a +# link points at. A bin directory that is itself a link, or that resolves +# outside the user's home, is not swept. # -# Linux only: a Mac's ~/.local/bin is the developer's own, and brew owns the -# system copies there. +# Names another tool also uses are not listed: pip's yq, Ubuntu's tea, sd and +# act are different programs, and a uv or pipx tool would be restored by its +# own updater anyway. # -# `local` names the distros where the managed copy is the /usr/local/bin -# binary; a distro package of the same tool there is a duplicate (below). -- name: Remove hand-installed duplicates of role-managed tools (Linux) +# Cargo homes are left to developer-rust, which knows the effective CARGO_HOME +# and deregisters a cargo-installed copy so `cargo install-update` does not +# bring it back. Linux only: a Mac's ~/.local/bin is the developer's own, and +# brew owns the system copies there. +- name: Remove user-level copies of role-managed tools (Linux) when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] vars: developer_managed_tools: - - {bin: kind, local: [Fedora, Ubuntu]} - - {bin: argocd, local: [Fedora, Ubuntu]} - - {bin: dive, local: [Fedora, Ubuntu]} - - {bin: kubeconform, local: [Fedora, Ubuntu]} - - {bin: kube-linter, local: [Fedora, Ubuntu]} - - {bin: k9s, local: [Ubuntu]} - - {bin: kustomize, local: [Ubuntu]} - - {bin: kubectx, local: [Fedora]} - - {bin: kubens, local: [Fedora]} - - {bin: yq, local: [Ubuntu]} - - {bin: gron, local: [Fedora]} - - {bin: sd, local: [Fedora]} - - {bin: lazygit, local: [Fedora, Ubuntu]} - - {bin: fnm, local: [Fedora, Ubuntu]} - - {bin: terraform-docs, local: [Fedora, Ubuntu]} - - {bin: aws-vault, local: [Fedora, Ubuntu]} - - {bin: golangci-lint, local: [Fedora, Ubuntu]} - - {bin: actionlint, local: [Fedora, Ubuntu]} - - {bin: hadolint, local: [Ubuntu]} - - {bin: gitleaks, local: [Ubuntu]} - - {bin: act, local: [Ubuntu]} - - {bin: osv-scanner, local: [Fedora, Ubuntu]} - - {bin: git-scrub, local: [Fedora, Ubuntu]} - - {bin: macbash, local: [Fedora, Ubuntu]} - - {bin: tea, local: [Fedora, Ubuntu]} - - {bin: sccache, local: [Fedora, Ubuntu]} - # Vendor-repository packages: the managed copy is /usr/bin. - - {bin: kubectl, local: []} - - {bin: helm, local: []} - - {bin: rpk, local: []} - - {bin: tofu, local: []} - - {bin: bao, local: []} + - kind + - argocd + - dive + - kubeconform + - kube-linter + - k9s + - kustomize + - kubectx + - kubens + - gron + - lazygit + - fnm + - terraform-docs + - aws-vault + - golangci-lint + - actionlint + - hadolint + - gitleaks + - osv-scanner + - git-scrub + - macbash + - sccache + - kubectl + - helm + - rpk + - tofu + - bao developer_user_bin_dirs: - "{{ user_home }}/.local/bin" - "{{ user_home }}/go/bin" - developer_tool_names: "{{ developer_managed_tools | map(attribute='bin') | list }}" developer_tool_paths: >- {{ (['/usr/local/bin', '/usr/bin'] + developer_user_bin_dirs) - | product(developer_tool_names) | map('join', '/') | list }} - # path -> "device:inode" for every candidate that resolves to a file. + | product(developer_managed_tools) | map('join', '/') | list }} + # path -> "device:inode" for every candidate that is a regular file after + # following links. Directories and dangling links drop out. developer_tool_ids: >- {{ dict(developer_tool_stat.stdout_lines | default([]) | map('regex_replace', '^\S+ ', '') | zip(developer_tool_stat.stdout_lines | default([]) | map('regex_replace', ' .*$', ''))) }} block: - # One stat for every candidate. Missing paths make stat exit non-zero and - # simply drop out of the output. + - name: Resolve the user's home + ansible.builtin.command: + argv: [readlink, -e, "{{ user_home }}"] + register: developer_user_home_real + changed_when: false + failed_when: false + check_mode: false + + - name: Inspect the user-level bin directories + ansible.builtin.stat: + path: "{{ item }}" + follow: false + loop: "{{ developer_user_bin_dirs }}" + become: true + become_user: "{{ actual_user }}" + register: developer_user_bin_dir_stat + + - name: Resolve the user-level bin directories + ansible.builtin.command: + argv: [readlink, -e, "{{ item.item }}"] + loop: "{{ developer_user_bin_dir_stat.results }}" + loop_control: + label: "{{ item.item }}" + register: developer_user_bin_dir_real + changed_when: false + failed_when: false + check_mode: false + when: + - item.stat.exists + - item.stat.isdir + - not item.stat.islnk + + # Missing paths make find exit non-zero and drop out of the output. - name: Identify the files behind role-managed tools and their user-level copies ansible.builtin.command: - argv: "{{ ['stat', '-L', '--printf', '%d:%i %n\\n', '--'] + developer_tool_paths }}" + argv: "{{ ['find', '-L'] + developer_tool_paths + ['-maxdepth', '0', '-type', 'f', '-printf', '%D:%i %p\\n'] }}" become: true become_user: "{{ actual_user }}" register: developer_tool_stat @@ -155,50 +189,90 @@ - name: Remove user-level copies that shadow a role-managed tool ansible.builtin.file: - path: "{{ item.0 }}/{{ item.1 }}" + path: "{{ item.0.item.item }}/{{ item.1 }}" state: absent become: true become_user: "{{ actual_user }}" - loop: "{{ developer_user_bin_dirs | product(developer_tool_names) | list }}" + loop: "{{ developer_user_bin_dir_real.results | product(developer_managed_tools) | list }}" loop_control: - label: "{{ item.0 }}/{{ item.1 }}" + label: "{{ item.0.item.item }}/{{ item.1 }}" when: - - (item.0 ~ '/' ~ item.1) in developer_tool_ids + - item.0.rc | default(1) == 0 + - developer_user_home_real.rc == 0 + - item.0.stdout.startswith(developer_user_home_real.stdout ~ '/') + - (item.0.item.item ~ '/' ~ item.1) in developer_tool_ids - developer_tool_system_id | length > 0 - - developer_tool_ids[item.0 ~ '/' ~ item.1] != developer_tool_system_id + - developer_tool_ids[item.0.item.item ~ '/' ~ item.1] != developer_tool_system_id vars: developer_tool_system_id: >- {{ ((['/usr/local/bin/' ~ item.1, '/usr/bin/' ~ item.1] | select('in', developer_tool_ids) | map('extract', developer_tool_ids) | list) + [''])[0] }} - # Where the managed copy is the /usr/local/bin binary, a package that also - # ships /usr/bin/ is a .deb or .rpm installed by hand beside it. The - # /usr/local/bin copy has to resolve under /usr/local, so purging the +# A .deb or .rpm of a tool whose managed copy is the /usr/local/bin binary, +# installed by hand beside it. Only packages named here go: each is the +# upstream release package of that tool, shipping /usr/bin/. A package +# of the same name that any repository offers is the distro's or a vendor's +# and stays, and so does everything when the apt lists are empty, because then +# no repository can be ruled out. +- name: Purge hand-installed release packages of /usr/local/bin tools (Linux) + when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] + vars: + developer_hand_packages: + - {name: macbash, distros: [Fedora, Ubuntu]} + - {name: git-scrub, distros: [Fedora, Ubuntu]} + - {name: dive, distros: [Fedora, Ubuntu]} + - {name: golangci-lint, distros: [Fedora, Ubuntu]} + - {name: k9s, distros: [Ubuntu]} + developer_hand_names: >- + {{ developer_hand_packages | selectattr('distros', 'contains', ansible_facts['distribution']) + | map(attribute='name') | list }} + developer_hand_ids: >- + {{ dict(developer_hand_stat.stdout_lines | default([]) | map('regex_replace', '^\S+ ', '') + | zip(developer_hand_stat.stdout_lines | default([]) | map('regex_replace', ' .*$', ''))) }} + block: + - name: Find the cached apt package lists (Ubuntu) + ansible.builtin.find: + paths: /var/lib/apt/lists + patterns: '*_Packages*' + register: developer_apt_lists + when: ansible_facts['distribution'] == 'Ubuntu' + + - name: Identify the /usr/local/bin and /usr/bin copies of the listed tools + ansible.builtin.command: + argv: >- + {{ ['find', '-L'] + + (['/usr/local/bin', '/usr/bin'] | product(developer_hand_names) | map('join', '/') | list) + + ['-maxdepth', '0', '-type', 'f', '-printf', '%D:%i %p\n'] }} + register: developer_hand_stat + changed_when: false + failed_when: false + check_mode: false + + # The /usr/local/bin copy has to resolve under /usr/local, so purging the # package cannot take the file it points at. - name: Resolve the /usr/local/bin copies that have a /usr/bin twin ansible.builtin.command: argv: [readlink, -e, "/usr/local/bin/{{ item }}"] - loop: >- - {{ developer_managed_tools | selectattr('local', 'contains', ansible_facts['distribution']) - | map(attribute='bin') | list }} - register: developer_tool_local_real + loop: "{{ developer_hand_names }}" + register: developer_hand_local_real changed_when: false failed_when: false check_mode: false when: - - ('/usr/local/bin/' ~ item) in developer_tool_ids - - ('/usr/bin/' ~ item) in developer_tool_ids - - developer_tool_ids['/usr/local/bin/' ~ item] != developer_tool_ids['/usr/bin/' ~ item] + - ('/usr/local/bin/' ~ item) in developer_hand_ids + - ('/usr/bin/' ~ item) in developer_hand_ids + - developer_hand_ids['/usr/local/bin/' ~ item] != developer_hand_ids['/usr/bin/' ~ item] + # dpkg-query prints `pkg: path` or `pkg:arch: path`, rpm the bare name. - name: Find the package that ships each /usr/bin twin ansible.builtin.command: argv: >- {{ (['dpkg-query', '-S'] if ansible_facts['distribution'] == 'Ubuntu' else ['rpm', '-qf', '--qf', '%{NAME}\n']) + ['/usr/bin/' ~ item.item] }} - loop: "{{ developer_tool_local_real.results }}" + loop: "{{ developer_hand_local_real.results }}" loop_control: label: "{{ item.item }}" - register: developer_tool_owner + register: developer_hand_owner changed_when: false failed_when: false check_mode: false @@ -206,93 +280,42 @@ - item.rc | default(1) == 0 - item.stdout.startswith('/usr/local/') - # dpkg-query prints `pkg: path` or `pkg:arch: path`; rpm the bare name. A - # diverted path adds a `diversion by` line naming no package. - - name: Collect the packages that duplicate a /usr/local/bin tool - ansible.builtin.set_fact: - developer_tool_dup_pkgs: >- - {{ developer_tool_owner.results | selectattr('rc', 'defined') | selectattr('rc', 'eq', 0) - | map(attribute='stdout_lines') | flatten | reject('match', '^diversion ') - | map('regex_replace', ':.*$', '') | unique | list }} - - # A package some repository still offers is the distro's or a vendor's, - # and updates arrive for it -- not a hand install. Ubuntu reads the cached - # lists; Fedora asks the enabled repos, and a query that fails keeps it. - - name: Check whether any repository offers each duplicate package + # Ubuntu reads the cached lists. Fedora asks the enabled repos, and a + # query that fails keeps the package. + - name: Check whether any repository offers each listed package ansible.builtin.command: argv: >- {{ (['apt-cache', 'madison'] if ansible_facts['distribution'] == 'Ubuntu' - else ['dnf', '-q', 'repoquery', '--available', '--qf', '%{name}\n']) + [item] }} - loop: "{{ developer_tool_dup_pkgs }}" - register: developer_tool_dup_origin - changed_when: false - failed_when: false - check_mode: false - - # Simulated first, so a package something else depends on stays: apt would - # remove the dependants with it, dnf likewise. - - name: Simulate removing each hand-installed duplicate package - ansible.builtin.command: - argv: >- - {{ (['apt-get', '-s', 'purge'] if ansible_facts['distribution'] == 'Ubuntu' - else ['rpm', '-e', '--test']) + [item.item] }} - loop: "{{ developer_tool_dup_origin.results }}" + else ['dnf', '-q', 'repoquery', '--available', '--qf', '%{name}\n']) + [item.item.item] }} + loop: "{{ developer_hand_owner.results }}" loop_control: - label: "{{ item.item }}" - register: developer_tool_dup_sim + label: "{{ item.item.item }}" + register: developer_hand_origin changed_when: false failed_when: false check_mode: false when: - - item.rc == 0 - - item.stdout | trim | length == 0 - - - name: Purge hand-installed packages that duplicate a /usr/local/bin tool (Ubuntu) - ansible.builtin.apt: - name: "{{ item.item.item }}" - state: absent - purge: true - autoremove: false - loop: "{{ developer_tool_dup_sim.results }}" - loop_control: - label: "{{ item.item.item }}" - when: - - ansible_facts['distribution'] == 'Ubuntu' - item.rc | default(1) == 0 - - developer_tool_dup_also | length == 0 - vars: - developer_tool_dup_also: >- - {{ item.stdout_lines | default([]) | select('match', '^(Purg|Remv) ') - | map('regex_replace', '^\S+ (\S+).*$', '\1') | reject('equalto', item.item.item | default('')) | list }} - - - name: Remove hand-installed packages that duplicate a /usr/local/bin tool (Fedora) - ansible.builtin.dnf: - name: "{{ item.item.item }}" - state: absent - autoremove: false - loop: "{{ developer_tool_dup_sim.results }}" - loop_control: - label: "{{ item.item.item }}" - when: - - ansible_facts['distribution'] == 'Fedora' - - item.rc | default(1) == 0 - - - name: Report duplicate packages kept because something depends on them - ansible.builtin.debug: - msg: >- - Kept the hand-installed {{ item.item.item }} package beside its - /usr/local/bin copy: removing it would also remove - {{ developer_tool_dup_also | join(', ') if developer_tool_dup_also else item.stderr | trim }}. - loop: "{{ developer_tool_dup_sim.results }}" + - item.stdout_lines | map('regex_replace', ':.*$', '') | list == [item.item.item] + + - name: Purge each hand-installed release package + ansible.builtin.include_role: + name: system_cleanup + tasks_from: purge_packages.yml + # The include is already gated by this file's tags, and its tasks carry none. + apply: + tags: ['always'] + loop: "{{ developer_hand_origin.results }}" loop_control: - label: "{{ item.item.item }}" + label: "{{ item.item.item.item }}" + vars: + system_cleanup_purge_packages: ["{{ item.item.item.item }}"] + system_cleanup_purge_reason: "the hand-installed {{ item.item.item.item }} package" when: - item.rc is defined - - item.rc != 0 or developer_tool_dup_also | length > 0 - vars: - developer_tool_dup_also: >- - {{ item.stdout_lines | default([]) | select('match', '^(Purg|Remv) ') - | map('regex_replace', '^\S+ (\S+).*$', '\1') | reject('equalto', item.item.item | default('')) | list }} + - item.rc == 0 + - item.stdout | trim | length == 0 + - ansible_facts['distribution'] != 'Ubuntu' or developer_apt_lists.matched | default(0) > 0 # ============================================================================ # CANNOT CO-EXIST — the old thing actively conflicts with its replacement. @@ -333,27 +356,38 @@ # still installed at /opt/docker-desktop (inactive) months after we stopped # installing it -- Ansible cannot prune what it stops declaring. # -# NOT autoremove. Docker Desktop on Linux ships a VM, so its dependency tree +# Its orphans stay. Docker Desktop on Linux ships a VM, so its dependency tree # pulls in the whole QEMU/OVMF stack plus uidmap -- 44 packages on a real host. # uidmap is the dangerous one: rootless containers call newuidmap at run time -# with no package dependency declaring it, so apt reclaims it and rootless -# breaks silently afterwards. -# -# The orphans stay. They are clutter, and `apt autoremove` remains available to -# whoever wants to reclaim them deliberately, having read the list. -- name: Remove Docker Desktop (Ubuntu - dropped by decision; Engine/CLI replace it) - ansible.builtin.apt: - name: docker-desktop - state: absent - when: ansible_facts['distribution'] == 'Ubuntu' +# with no package dependency declaring it, so an autoremove reclaims it and +# rootless breaks silently afterwards. hyperi-update autoremoves unattended, so +# the shared purge marks every package the removal would orphan as manually +# installed, and reclaiming them stays a deliberate act. +- name: Check for Docker Desktop (Linux) + ansible.builtin.command: + argv: >- + {{ ['dpkg-query', '-W', '-f', '${db:Status-Abbrev}', 'docker-desktop'] + if ansible_facts['distribution'] == 'Ubuntu' else ['rpm', '-q', 'docker-desktop'] }} + register: developer_docker_desktop_installed + changed_when: false failed_when: false + check_mode: false + when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] -- name: Remove Docker Desktop (Fedora - dropped by decision; Engine/CLI replace it) - ansible.builtin.dnf: - name: docker-desktop - state: absent - when: ansible_facts['distribution'] == 'Fedora' - failed_when: false +- name: Remove Docker Desktop (Linux - dropped by decision; Engine/CLI replace it) + ansible.builtin.include_role: + name: system_cleanup + tasks_from: purge_packages.yml + # The include is already gated by this file's tags, and its tasks carry none. + apply: + tags: ['always'] + vars: + system_cleanup_purge_packages: [docker-desktop] + system_cleanup_purge_reason: Docker Desktop + when: + - ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] + - developer_docker_desktop_installed.rc == 0 + - ansible_facts['distribution'] != 'Ubuntu' or developer_docker_desktop_installed.stdout is match('^ii') # macOS: the Docker Desktop cask is `docker`; the CLI-only install is the brew # FORMULA `docker` (see docker.yml), a different artefact, so removing the cask diff --git a/ansible/roles/soe/templates/hyperi-update.service.j2 b/ansible/roles/soe/templates/hyperi-update.service.j2 index 40a908b..1cd49ab 100644 --- a/ansible/roles/soe/templates/hyperi-update.service.j2 +++ b/ansible/roles/soe/templates/hyperi-update.service.j2 @@ -12,5 +12,9 @@ User={{ actual_user }} # user-scoped language tools update. Tier-1 OS packages are also covered # independently by unattended-upgrades / dnf-automatic (security.yml). ExecStart=/usr/local/bin/hyperi-update --yes +# A unit gets no PAM session, so a CARGO_HOME or RUSTUP_HOME declared in +# /etc/environment would otherwise never reach the cargo steps. `-`: the file +# may be absent. +EnvironmentFile=-/etc/environment # A slow mirror or a large upgrade must not be killed mid-run. TimeoutStartSec=3600 diff --git a/ansible/roles/system_cleanup/tasks/purge_packages.yml b/ansible/roles/system_cleanup/tasks/purge_packages.yml new file mode 100644 index 0000000..69c1150 --- /dev/null +++ b/ansible/roles/system_cleanup/tasks/purge_packages.yml @@ -0,0 +1,119 @@ +--- +# Remove system_cleanup_purge_packages without taking anything else with it, now or +# later. +# +# Now: a removal that would also take a package outside the list -- something +# depends on one of them -- is refused and reported instead. +# +# Later: dependencies only these packages needed become orphans, and +# hyperi-update runs `apt-get autoremove` / `dnf autoremove` unattended, which +# would then remove them -- gcc, binutils and libc6-dev among them for the +# distro Go. Anything the removal would newly orphan is marked manually +# installed first, so it stays until someone removes it on purpose. +# +# Inputs: system_cleanup_purge_packages (installed package names) and +# system_cleanup_purge_reason (why they go, for the report). + +- name: Simulate removing {{ system_cleanup_purge_reason }} + ansible.builtin.command: + argv: >- + {{ (['apt-get', '-s', 'purge'] if ansible_facts['distribution'] == 'Ubuntu' + else ['rpm', '-e', '--test']) + system_cleanup_purge_packages }} + become: true + register: system_cleanup_purge_sim + changed_when: false + failed_when: false + check_mode: false + +# apt names a dependant in the plan; rpm refuses outright. +- name: Note what else the removal would take, for {{ system_cleanup_purge_reason }} + ansible.builtin.set_fact: + system_cleanup_purge_blocked: >- + {{ system_cleanup_purge_sim.rc != 0 + or (system_cleanup_purge_sim.stdout_lines | default([]) | select('match', '^(Purg|Remv) ') + | map('regex_replace', '^\S+ (\S+).*$', '\1') | reject('in', system_cleanup_purge_packages) + | list | length > 0) }} + system_cleanup_purge_dependants: >- + {{ system_cleanup_purge_sim.stdout_lines | default([]) | select('match', '^(Purg|Remv) ') + | map('regex_replace', '^\S+ (\S+).*$', '\1') | reject('in', system_cleanup_purge_packages) | list }} + +- name: Report a package kept because something depends on it, {{ system_cleanup_purge_reason }} + ansible.builtin.debug: + msg: >- + Kept {{ system_cleanup_purge_packages | join(', ') }} ({{ system_cleanup_purge_reason }}): + {{ ('removing it would also remove ' ~ system_cleanup_purge_dependants | join(', ')) + if system_cleanup_purge_dependants + else ('the package manager refused the removal: ' + ~ system_cleanup_purge_sim.stderr | default('') | trim) }} + when: system_cleanup_purge_blocked | bool + +- name: Remove the packages, {{ system_cleanup_purge_reason }} + when: not system_cleanup_purge_blocked | bool + block: + - name: List the packages already orphaned + ansible.builtin.command: + argv: >- + {{ ['apt-get', '-s', 'autoremove'] if ansible_facts['distribution'] == 'Ubuntu' + else ['dnf', '-q', 'repoquery', '--unneeded', '--qf', '%{name}\n'] }} + become: true + register: system_cleanup_purge_orphans_before + changed_when: false + failed_when: false + check_mode: false + + - name: List the packages the removal would orphan + ansible.builtin.command: + argv: >- + {{ (['apt-get', '-s', 'purge', '--autoremove'] if ansible_facts['distribution'] == 'Ubuntu' + else ['dnf', 'remove', '--assumeno']) + system_cleanup_purge_packages }} + become: true + register: system_cleanup_purge_orphans_after + changed_when: false + failed_when: false + check_mode: false + + # apt: the Remv/Purg lines of each plan. dnf: the "Removing unused + # dependencies" table of the plan, and the bare names of the query. + - name: Name the packages the removal newly orphans + ansible.builtin.set_fact: + system_cleanup_purge_new_orphans: >- + {{ (_after | reject('in', _before) | reject('in', system_cleanup_purge_packages) | list) }} + vars: + _before: >- + {{ (system_cleanup_purge_orphans_before.stdout_lines | default([]) | select('match', '^(Purg|Remv) ') + | map('regex_replace', '^\S+ (\S+).*$', '\1') | list) + if ansible_facts['distribution'] == 'Ubuntu' + else (system_cleanup_purge_orphans_before.stdout_lines | default([]) | map('trim') | select | list) }} + _after: >- + {{ (system_cleanup_purge_orphans_after.stdout_lines | default([]) | select('match', '^(Purg|Remv) ') + | map('regex_replace', '^\S+ (\S+).*$', '\1') | list) + if ansible_facts['distribution'] == 'Ubuntu' + else ((system_cleanup_purge_orphans_after.stdout | default('') + | regex_findall('(?s)Removing unused dependencies:\n(.*?)(?:\n\S|\n\n|$)') | first | default('')) + .splitlines() | map('trim') | select | map('regex_replace', ' .*$', '') | list) }} + + - name: Keep the packages the removal would orphan + ansible.builtin.command: + argv: >- + {{ (['apt-mark', 'manual'] if ansible_facts['distribution'] == 'Ubuntu' + else ['dnf', '-y', 'mark', 'user']) + system_cleanup_purge_new_orphans }} + become: true + changed_when: true + when: system_cleanup_purge_new_orphans | length > 0 + + - name: Purge on Ubuntu, {{ system_cleanup_purge_reason }} + ansible.builtin.apt: + name: "{{ system_cleanup_purge_packages }}" + state: absent + purge: true + autoremove: false + become: true + when: ansible_facts['distribution'] == 'Ubuntu' + + - name: Remove on Fedora, {{ system_cleanup_purge_reason }} + ansible.builtin.dnf: + name: "{{ system_cleanup_purge_packages }}" + state: absent + autoremove: false + become: true + when: ansible_facts['distribution'] == 'Fedora' diff --git a/docs/install-matrix.md b/docs/install-matrix.md index aa78f13..6e865c9 100644 --- a/docs/install-matrix.md +++ b/docs/install-matrix.md @@ -609,7 +609,9 @@ it. Every channel move needs a tombstone for the path it vacated, and the `remediation` molecule scenario asserts the replacement is what `which` resolves to -- not merely that the new thing installed. -**A hand install leaves a second copy too.** The `removals` tag clears copies of the managed tools in `~/.local/bin` and `~/go/bin`, and a hand-installed .deb or .rpm that duplicates a `/usr/local/bin` tool, each only where the managed copy exists and is a different file. developer-go removes the distro Go once `/usr/local/go` is in, and developer-rust clears cargo-home duplicates, cargo-tarpaulin and a `~/.cargo/bin` left behind by a relocated `CARGO_HOME`. A package something else depends on stays, and the run says which. +**A hand install leaves a second copy too.** The `removals` tag clears copies of the managed tools in `~/.local/bin` and `~/go/bin` where the managed copy exists and is a different file. A deliberate pin there (an older kubectl, golangci-lint v1) goes too, so pin per project instead. Tools whose name another program also uses (yq, tea, sd, act) are left alone. It also purges the upstream .deb or .rpm of macbash, git-scrub, dive, golangci-lint or k9s installed beside the `/usr/local/bin` copy, unless a repository offers a package of that name. + +developer-go removes the distro Go once a working `/usr/local/go` is in and links `go` and `gofmt` into `/usr/local/bin`. developer-rust clears cargo-home duplicates, cargo-tarpaulin and, where the host exports a relocated `CARGO_HOME`, the old `~/.cargo/bin` binaries the effective home also holds. A package something else depends on stays, and the run says which. Packages a purge would orphan are marked manually installed, so `hyperi-update`'s autoremove does not take them later. ## Auto-update From 02e48e0195869bcc8fc5601011362b4354ac8980 Mon Sep 17 00:00:00 2001 From: Derek Date: Tue, 6 Oct 2026 18:51:09 +1100 Subject: [PATCH 3/5] fix: make package purges locale-safe and opt-in The shared purge read dnf's translated table headers, so under a German locale it found no orphans, removed the distro Go and left gcc, binutils and glibc-devel for the next autoremove. Every parsed command now runs in the C locale, and the purge stops unless dnf's summary count matches the packages it parsed. It also stops when any simulation fails. It marks only the new orphans the purged packages depend on directly (requires and recommends), which keeps their own dependencies too, instead of every new orphan. Removing what the roles did not install now runs only on a removals or soe run, like the developer role's tombstones: the distro Go purge, the cargo-home duplicates and the stale ~/.cargo sweep. A --tags removals run reaches the cargo-home sweep through a new developer-rust removals.yml, and the cargo-home resolution moved to cargo_home.yml so both paths share it. cargo-tarpaulin is still retired on every run, as this role installed it. A CARGO_HOME the host declares but has not created yet counts as declared, so a first run no longer warns. The distro Go goes only when /usr/local/go/src/runtime also resolves under /usr/local, because a copy of Ubuntu's tree keeps its standard library as links into /usr/share. go and gofmt in /usr/local/bin are pointed at /usr/local/go only when missing, dangling or already pointing there, and anything else is reported. The distro Rust removal failed with a depsolve error on any Fedora host with rust-std-static. It now removes every package built from the distro's Rust sources (rust-defaults and rustc-N.NN on Ubuntu) through the same purge. hyperi-update reads CARGO_HOME and RUSTUP_HOME from sentinel lines the login shell prints into a temporary file, takes only absolute paths, and no longer waits on a child a profile left in the background. Both updaters exit 1 when any step failed, so the systemd unit shows the failure. The remediation fixture removes dpkg-dev and rpm-build before installing Go and asserts the Go removal would orphan gcc, copies the Go tree with links dereferenced, builds with the managed Go in verify, and adds a gosrc host whose /usr/local/go still links into /usr/share. --- ansible/molecule/remediation/molecule.yml | 10 +- ansible/molecule/remediation/prepare.yml | 62 +++++- ansible/molecule/remediation/verify.yml | 28 ++- ansible/roles/developer-go/tasks/go.yml | 44 ++++- ansible/roles/developer-go/tasks/main.yml | 10 +- .../roles/developer-go/tasks/superseded.yml | 7 +- ansible/roles/developer-rust/README.md | 5 +- .../roles/developer-rust/tasks/cargo_home.yml | 119 ++++++++++++ ansible/roles/developer-rust/tasks/main.yml | 12 ++ .../roles/developer-rust/tasks/removals.yml | 13 ++ ansible/roles/developer-rust/tasks/rust.yml | 183 ++++-------------- ansible/roles/developer-rust/tasks/strays.yml | 27 ++- .../files/update/hyperi-update-linux.sh | 36 ++-- .../files/update/hyperi-update-macos.sh | 4 + ansible/roles/developer/tasks/init.yml | 3 +- ansible/roles/developer/tasks/removals.yml | 6 +- .../system_cleanup/tasks/purge_packages.yml | 99 ++++++++-- docs/install-matrix.md | 2 +- 18 files changed, 463 insertions(+), 207 deletions(-) create mode 100644 ansible/roles/developer-rust/tasks/cargo_home.yml create mode 100644 ansible/roles/developer-rust/tasks/removals.yml diff --git a/ansible/molecule/remediation/molecule.yml b/ansible/molecule/remediation/molecule.yml index 30dd13d..c681099 100644 --- a/ansible/molecule/remediation/molecule.yml +++ b/ansible/molecule/remediation/molecule.yml @@ -34,8 +34,9 @@ driver: # pre_build_image: false so molecule builds an Ansible-compatible layer over the # stock image. The base ships no python3, and every module needs one. # -# The golink host differs only in the Go fixture: its /usr/local/go links into -# the distro tree, so it asserts the distro Go is kept (prepare.yml). +# The golink and gosrc hosts differ only in the Go fixture: /usr/local/go links +# into the distro tree, or copies it with its standard library still linked +# there, so both assert the distro Go is kept (prepare.yml). platforms: - name: hyperi-remediation-ubuntu image: docker.io/library/ubuntu:26.04 @@ -47,6 +48,11 @@ platforms: pre_build_image: false command: /bin/sleep infinity privileged: false + - name: hyperi-remediation-ubuntu-gosrc + image: docker.io/library/ubuntu:26.04 + pre_build_image: false + command: /bin/sleep infinity + privileged: false ansible: cfg: diff --git a/ansible/molecule/remediation/prepare.yml b/ansible/molecule/remediation/prepare.yml index bcd0994..9591521 100644 --- a/ansible/molecule/remediation/prepare.yml +++ b/ansible/molecule/remediation/prepare.yml @@ -599,11 +599,30 @@ disable_gpg_check: true state: present + # The package-building tools would keep gcc, binutils and libc6-dev (or + # glibc-devel) installed whatever happens to Go, so the Go purge below + # could never orphan them. They go before Go comes in. + - name: Remove the package-building tools (Ubuntu) + ansible.builtin.apt: + name: dpkg-dev + state: absent + purge: true + autoremove: true + when: ansible_facts['distribution'] == 'Ubuntu' + + - name: Remove the package-building tools (Fedora) + ansible.builtin.dnf: + name: rpm-build + state: absent + autoremove: true + when: ansible_facts['distribution'] == 'Fedora' + # Observed on the reference workstation 2026-10-06: the distro Go with no # reverse dependencies beside the role's /usr/local/go. The distro's own - # tree is copied into /usr/local/go, so `go version` runs as the role's - # would. The golink host instead links /usr/local/go INTO the distro tree, - # which is the distro Go and must stay. + # tree is copied into /usr/local/go with every link dereferenced, so it is + # a self-contained toolchain as the role's would be. Two hosts keep the + # distro Go: on golink /usr/local/go links into the distro tree, and on + # gosrc it is a plain copy whose src still links into /usr/share. - name: Install the distro Go ansible.builtin.package: name: "{{ 'golang-go' if ansible_facts['distribution'] == 'Ubuntu' else 'golang' }}" @@ -617,7 +636,11 @@ - name: Copy the distro Go tree into /usr/local/go ansible.builtin.command: - argv: [cp, -a, "{{ prepare_distro_go.stdout | dirname | dirname }}", /usr/local/go] + argv: + - cp + - "{{ '-a' if 'gosrc' in inventory_hostname else '-aL' }}" + - "{{ prepare_distro_go.stdout | dirname | dirname }}" + - /usr/local/go creates: /usr/local/go when: "'golink' not in inventory_hostname" @@ -628,6 +651,37 @@ state: link when: "'golink' in inventory_hostname" + # The fixture has to reach the orphan path it exists to test: removing the + # distro Go must, by itself, leave gcc for an autoremove to take. + - name: Find the installed distro Go packages + ansible.builtin.shell: + cmd: >- + {{ "set -o pipefail; dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' 'golang*' | sed -n 's/^ii *//p'" + if ansible_facts['distribution'] == 'Ubuntu' + else "rpm -q --qf '%{NAME}\n' golang golang-bin golang-src" }} + executable: /bin/bash + register: prepare_go_packages + changed_when: false + + - name: Simulate removing the distro Go with its orphans + ansible.builtin.command: + argv: >- + {{ (['apt-get', '-s', 'purge', '--autoremove'] if ansible_facts['distribution'] == 'Ubuntu' + else ['dnf', 'remove', '--assumeno']) + prepare_go_packages.stdout_lines }} + environment: {LC_ALL: C.UTF-8} + register: prepare_go_orphans + changed_when: false + failed_when: false + + - name: Assert removing the distro Go would orphan gcc + ansible.builtin.assert: + that: + - prepare_go_orphans.stdout is search('(?m)^(Remv|Purg) gcc |^ gcc ') + fail_msg: >- + Removing the distro Go would not orphan gcc here, so the Go purge's + orphan handling is not exercised by this fixture. + quiet: true + # DERIVED: developer-go/tasks/go.yml writes this beside the toolchain. - name: Plant the managed Go's profile drop-in ansible.builtin.copy: diff --git a/ansible/molecule/remediation/verify.yml b/ansible/molecule/remediation/verify.yml index 282d8f5..cac2283 100644 --- a/ansible/molecule/remediation/verify.yml +++ b/ansible/molecule/remediation/verify.yml @@ -339,7 +339,8 @@ {{ ansible_facts.packages.keys() | list if ansible_facts['distribution'] == 'Ubuntu' else verify_rpms.stdout_lines }} - # On the golink host /usr/local/go IS the distro tree, so the distro Go + # On the golink host /usr/local/go IS the distro tree, and on gosrc its + # standard library still links into the distro's, so the distro Go # stays. On Fedora the repository golangci-lint requires golang, so it stays # there too. - name: Assert the hand-installed and distro duplicates were removed @@ -350,7 +351,7 @@ success_msg: "{{ item }} removed" loop: >- {{ ['^macbash$'] - + ([] if 'golink' in inventory_hostname else + + ([] if inventory_hostname is search('golink|gosrc') else (['^golang-go$', '^golang-src$', '^golang-[0-9.]+-go$'] if ansible_facts['distribution'] == 'Ubuntu' else [])) }} @@ -364,10 +365,31 @@ success_msg: "{{ item }} kept" loop: >- {{ ((['git-scrub', 'git-scrub-dependant', 'kind', 'dive'] - + (['golang-go'] if 'golink' in inventory_hostname else [])) + + (['golang-go'] if inventory_hostname is search('golink|gosrc') else [])) if ansible_facts['distribution'] == 'Ubuntu' else ['golangci-lint', 'golang']) }} + # A managed Go that still runs `go version` can have lost its standard + # library with the distro packages, so it has to build something. + - name: Write a program for the managed Go to build + ansible.builtin.copy: + content: | + package main + + func main() { println("ok") } + dest: /root/verify-hello.go + mode: '0644' + + - name: Build with the managed Go + ansible.builtin.command: + argv: [/usr/local/go/bin/go, run, /root/verify-hello.go] + environment: + GOTOOLCHAIN: local + GOFLAGS: -mod=mod + register: verify_go_build + changed_when: false + failed_when: verify_go_build.rc != 0 + # The Go purge must not leave the compiler toolchain for the updater's # unattended autoremove to take. - name: Simulate the updater's autoremove (Ubuntu) diff --git a/ansible/roles/developer-go/tasks/go.yml b/ansible/roles/developer-go/tasks/go.yml index 2e07b24..e3b062d 100644 --- a/ansible/roles/developer-go/tasks/go.yml +++ b/ansible/roles/developer-go/tasks/go.yml @@ -104,8 +104,8 @@ group: root mode: '0644' - # The profile drop-in reaches login shells only. Cron, systemd units and - # anything else on the default PATH find go through these links. + # The profile drop-in reaches login shells only. systemd units and any + # shell started on the default PATH find go through these links. - name: Check for go and gofmt in /usr/local/bin ansible.builtin.stat: path: "/usr/local/bin/{{ item }}" @@ -113,18 +113,44 @@ loop: [go, gofmt] register: developer_go_bin_links - # A real file there is someone else's, so only a missing entry or a link - # is pointed at the toolchain. + - name: Check whether those links resolve + ansible.builtin.stat: + path: "/usr/local/bin/{{ item }}" + follow: true + loop: [go, gofmt] + register: developer_go_bin_targets + + # Only a missing entry, a dangling link, or a link already into + # /usr/local/go is this role's to point at the toolchain. - name: Link go and gofmt into /usr/local/bin ansible.builtin.file: - src: "/usr/local/go/bin/{{ item.item }}" - dest: "/usr/local/bin/{{ item.item }}" + src: "/usr/local/go/bin/{{ item.0.item }}" + dest: "/usr/local/bin/{{ item.0.item }}" state: link force: true - loop: "{{ developer_go_bin_links.results }}" + loop: "{{ developer_go_bin_links.results | zip(developer_go_bin_targets.results) | list }}" loop_control: - label: "{{ item.item }}" - when: not item.stat.exists or item.stat.islnk + label: "{{ item.0.item }}" + when: >- + not item.0.stat.exists + or (item.0.stat.islnk + and (not item.1.stat.exists or item.0.stat.lnk_target is match('^/usr/local/go/'))) + + # noqa: var-naming[no-role-prefix] -- deploy_warnings is the shared + # accumulator the playbook reports at the end of the run. + - name: Report go and gofmt paths held by something else + ansible.builtin.set_fact: + deploy_warnings: >- + {{ (deploy_warnings | default([]) + + ['Go toolchain: /usr/local/bin/' ~ item.0.item ~ ' is not a link this role' + ~ ' manages, so it was left alone and does not point at /usr/local/go.']) | unique }} + loop: "{{ developer_go_bin_links.results | zip(developer_go_bin_targets.results) | list }}" + loop_control: + label: "{{ item.0.item }}" + when: + - item.0.stat.exists + - not item.0.stat.islnk + or (item.1.stat.exists and item.0.stat.lnk_target is not match('^/usr/local/go/')) - name: Install delve (Fedora) ansible.builtin.dnf: diff --git a/ansible/roles/developer-go/tasks/main.yml b/ansible/roles/developer-go/tasks/main.yml index 2d43727..079e8b0 100644 --- a/ansible/roles/developer-go/tasks/main.yml +++ b/ansible/roles/developer-go/tasks/main.yml @@ -9,11 +9,15 @@ tags: ['developer-go', 'go'] # After go.yml, so /usr/local/go is in before the distro Go it supersedes goes. -# `removals` also reaches it, for a remediation run that installs nothing. -- name: Remove the distro Go superseded by the managed toolchain +# Removing a package this role did not install is a HyperI-fleet action, so it +# runs only on a `removals` or `soe` run, like the developer role's tombstones. +# Until then the /usr/local/bin links go.yml makes put the managed Go first. +- name: Remove the distro Go superseded by the managed toolchain (opt-in) ansible.builtin.include_tasks: file: superseded.yml apply: tags: ['developer-go', 'go', 'removals'] tags: ['developer-go', 'go', 'removals'] - when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] + when: + - ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] + - "'removals' in ansible_run_tags or 'soe' in ansible_run_tags" diff --git a/ansible/roles/developer-go/tasks/superseded.yml b/ansible/roles/developer-go/tasks/superseded.yml index 1e8f9af..7597650 100644 --- a/ansible/roles/developer-go/tasks/superseded.yml +++ b/ansible/roles/developer-go/tasks/superseded.yml @@ -6,7 +6,9 @@ # # Removed only when the managed Go is provably this role's and works: the # profile drop-in go.yml writes exists, /usr/local/go/bin/go resolves under -# /usr/local to a different file than /usr/bin/go, and `go version` runs. A +# /usr/local to a different file than /usr/bin/go, `go version` runs, and its +# standard library (src/runtime) resolves under /usr/local too -- a tree copied +# from Ubuntu's keeps src as a link into /usr/share and breaks with the purge. A # /usr/local/go linked into the distro tree is the distro Go, and it stays. # Only packages named golang* are candidates, so gccgo is never touched. # @@ -18,6 +20,7 @@ loop: - /usr/local/go/bin/go - /usr/bin/go + - /usr/local/go/src/runtime register: developer_go_resolved changed_when: false failed_when: false @@ -45,6 +48,8 @@ - developer_go_resolved.results[0].stdout.startswith('/usr/local/') - not developer_go_resolved.results[1].stdout.startswith('/usr/local/') - developer_go_resolved.results[0].stdout != developer_go_resolved.results[1].stdout + - developer_go_resolved.results[2].rc == 0 + - developer_go_resolved.results[2].stdout.startswith('/usr/local/') block: # The package that ships the resolved binary (golang-1.26-go on Ubuntu, # golang-bin on Fedora), plus the metapackages that pull it in. diff --git a/ansible/roles/developer-rust/README.md b/ansible/roles/developer-rust/README.md index c4a1666..a223132 100644 --- a/ansible/roles/developer-rust/README.md +++ b/ansible/roles/developer-rust/README.md @@ -166,12 +166,11 @@ This does not reopen the objection in the SSoT note below: crates.io stays out of the global `rustc-wrapper` path. The binary is the project's own release artefact, digest-checked, not an unpinned `cargo install`. -**A cargo-installed sccache would shadow it.** The cargo bin directory precedes `/usr/local/bin` on PATH, so anything typed by hand reaches the cargo copy while builds keep using the absolute path in the cargo config. That split is what makes a failed `--show-stats` look like a dead cache when every build is being cached normally. On Linux the role deregisters and removes the cargo copy once the managed one is in place (`tasks/strays.yml`). The setup tool run on its own only prints the `cargo uninstall` line. +**A cargo-installed sccache would shadow it.** The cargo bin directory precedes `/usr/local/bin` on PATH, so anything typed by hand reaches the cargo copy while builds keep using the absolute path in the cargo config. That split is what makes a failed `--show-stats` look like a dead cache when every build is being cached normally. On Linux a `removals` or `soe` run deregisters and removes the cargo copy once the managed one is in place (`tasks/strays.yml`). The setup tool run on its own only prints the `cargo uninstall` line. -**Strays.** The role clears what would otherwise run instead of the managed copies: +**Strays.** cargo-tarpaulin is retired on every run. The rest runs only on a `removals` or `soe` run, because it removes what the role did not install: - cargo-home copies of sccache, sd, fnm, uv and uvx, once the managed copy exists and is a different file (Linux only) -- the retired cargo-tarpaulin - where `CARGO_HOME` is relocated, binaries in the old `~/.cargo/bin` that the effective home also holds A cargo-installed copy goes through `cargo uninstall --root`, so `cargo install-update` does not reinstall it, and a `~/.cargo` package whose binaries all go is deregistered there too. The registry and git caches and anything installed only in `~/.cargo` stay on disk. They drop off PATH once the relocation is in effect, because the shell profile and the SOE PATH drop-in name `${CARGO_HOME:-$HOME/.cargo}/bin`. diff --git a/ansible/roles/developer-rust/tasks/cargo_home.yml b/ansible/roles/developer-rust/tasks/cargo_home.yml new file mode 100644 index 0000000..b9db470 --- /dev/null +++ b/ansible/roles/developer-rust/tasks/cargo_home.yml @@ -0,0 +1,119 @@ +--- +# Where the toolchain actually lives, and the cargo environment every cargo +# task runs under. Included from rust.yml and from removals.yml, so a +# `--tags removals` run resolves the same homes an install run does. +# +# Everything addresses cargo through these facts, never through a hard-coded +# ~/.cargo. Cargo reads $CARGO_HOME/config.toml and rustup reads $RUSTUP_HOME, +# and neither is required to be under the home directory -- a box with a +# dedicated toolchain volume relocates both. Writing to ~/.cargo on such a host +# produces a file the tool silently ignores while a stale one stays in effect. + +# An explicit role variable, else what the target user's login environment +# exports (probed in the developer role's init.yml), else the upstream default, +# so nothing downstream is ever templated blank. +- name: Resolve the effective cargo and rustup homes + ansible.builtin.set_fact: + developer_rust_cargo_home: >- + {{ rust_cargo_home | default(hyperi_host_cargo_home | default(''), true) + | default(user_home ~ '/.cargo', true) | regex_replace('/+$', '') }} + developer_rust_rustup_home: >- + {{ rust_rustup_home | default(hyperi_host_rustup_home | default(''), true) + | default(user_home ~ '/.rustup', true) | regex_replace('/+$', '') }} + +# Absolute, and free of the two characters that would break the root-owned +# unit file this value is templated into: `%` is a systemd specifier and `"` +# ends the quoted assignment. +- name: Check the resolved toolchain paths are usable + ansible.builtin.assert: + that: + - developer_rust_cargo_home is match('^/') + - developer_rust_rustup_home is match('^/') + - developer_rust_cargo_home is not search('[%"]') + - developer_rust_rustup_home is not search('[%"]') + fail_msg: >- + Resolved CARGO_HOME={{ developer_rust_cargo_home }}, + RUSTUP_HOME={{ developer_rust_rustup_home }} -- not an absolute path, or + contains % or ". Set rust_cargo_home/rust_rustup_home explicitly. + quiet: true + +# Compared by device and inode, not by path: ~/.cargo can be a symlink to the +# relocated home, and then it is the live home, not a stale one. +- name: Identify the default, the effective and the host-exported cargo homes + ansible.builtin.stat: + path: "{{ item }}" + follow: true + get_checksum: false + loop: + - "{{ user_home }}/.cargo" + - "{{ developer_rust_cargo_home }}" + - "{{ hyperi_host_cargo_home | default('', true) or '/nonexistent/hyperi-no-cargo-home' }}" + become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" + register: developer_rust_cargo_home_dirs + +# ~/.cargo is stale only when it is a separate directory from the effective +# home AND the host itself exports that effective home: cargo, hyperi-update and +# every shell then use it, and nothing still reads ~/.cargo. A home set only by +# rust_cargo_home is not proof -- the host's own cargo may still be ~/.cargo. +# The exported path matching by name counts too, so a home not created yet on a +# first run is not mistaken for an undeclared one. +- name: Note whether ~/.cargo is a stale home beside a relocated one + ansible.builtin.set_fact: + developer_rust_cargo_home_relocated: "{{ _separate | bool }}" + developer_rust_cargo_home_stale: "{{ _separate | bool and _declared | bool }}" + vars: + _default: "{{ developer_rust_cargo_home_dirs.results[0].stat }}" + _effective: "{{ developer_rust_cargo_home_dirs.results[1].stat }}" + _host: "{{ developer_rust_cargo_home_dirs.results[2].stat }}" + _separate: >- + {{ _default.exists and _default.isdir + and not (_effective.exists and _effective.dev == _default.dev and _effective.inode == _default.inode) }} + _declared: >- + {{ (hyperi_host_cargo_home | default('', true) | length > 0 + and hyperi_host_cargo_home == developer_rust_cargo_home) + or (_effective.exists and _host.exists + and _effective.dev == _host.dev and _effective.inode == _host.inode) }} + +- name: Record that ~/.cargo was left alone beside an undeclared CARGO_HOME + # noqa: var-naming[no-role-prefix] -- deploy_warnings is the shared accumulator + ansible.builtin.set_fact: + deploy_warnings: >- + {{ (deploy_warnings | default([]) + + ['cargo home: the role used ' ~ developer_rust_cargo_home ~ ' but the login environment of ' + ~ actual_user ~ ' exports ' ~ (hyperi_host_cargo_home | default('', true) or 'no CARGO_HOME') + ~ ', so ' ~ user_home ~ '/.cargo was neither swept nor had its config.toml retired.' + ~ ' Declare CARGO_HOME on the host (for example in /etc/environment) and re-run.']) | unique }} + when: + - developer_rust_cargo_home_relocated | bool + - not developer_rust_cargo_home_stale | bool + +- name: Report where the toolchain was resolved to + ansible.builtin.debug: + msg: >- + CARGO_HOME={{ developer_rust_cargo_home }} + RUSTUP_HOME={{ developer_rust_rustup_home }} + verbosity: 1 + +- name: Set cargo environment (Linux) + ansible.builtin.set_fact: + cargo_env: + PATH: "{{ developer_rust_cargo_home }}/bin:{{ ansible_facts['env'].PATH }}" + CARGO_HOME: "{{ developer_rust_cargo_home }}" + RUSTUP_HOME: "{{ developer_rust_rustup_home }}" + when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] + +# /opt/homebrew/opt/rustup/bin leads: brew's rustup never links its shims +# into /opt/homebrew/bin, so without it no cargo resolves on a rustup Mac. +- name: Set cargo environment (macOS) + ansible.builtin.set_fact: + cargo_env: + PATH: >- + {{ '/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:' + ~ developer_rust_cargo_home ~ '/bin:' ~ ansible_facts['env'].PATH }} + CARGO_HOME: "{{ developer_rust_cargo_home }}" + # Resolves to ~/.rustup when the host says nothing, which is what rustup + # would have defaulted to anyway. Carried explicitly so a Mac that + # relocates it is not the one platform still hard-coded. + RUSTUP_HOME: "{{ developer_rust_rustup_home }}" + when: ansible_facts['distribution'] == 'MacOSX' diff --git a/ansible/roles/developer-rust/tasks/main.yml b/ansible/roles/developer-rust/tasks/main.yml index 8589bfa..8484bde 100644 --- a/ansible/roles/developer-rust/tasks/main.yml +++ b/ansible/roles/developer-rust/tasks/main.yml @@ -22,3 +22,15 @@ apply: tags: ['developer-rust', 'rust-governor'] tags: ['developer-rust', 'rust-governor'] + +# A `--tags removals` run reaches the cargo-home sweep here, since rust.yml is +# not selected by that tag. An install run sweeps from rust.yml instead. +- name: Remove cargo-home strays (opt-in) + ansible.builtin.include_tasks: + file: removals.yml + apply: + tags: ['removals', 'never'] + tags: ['removals', 'never'] + when: + - "'removals' in ansible_run_tags" + - "'developer-rust' not in ansible_run_tags and 'rust' not in ansible_run_tags" diff --git a/ansible/roles/developer-rust/tasks/removals.yml b/ansible/roles/developer-rust/tasks/removals.yml new file mode 100644 index 0000000..7296104 --- /dev/null +++ b/ansible/roles/developer-rust/tasks/removals.yml @@ -0,0 +1,13 @@ +--- +# The cargo-home sweep for a `--tags removals` run, which installs nothing and +# so never reaches rust.yml. + +- name: Resolve the cargo homes and the cargo environment + ansible.builtin.include_tasks: + file: cargo_home.yml + +- name: Remove cargo-home strays + ansible.builtin.include_tasks: + file: strays.yml + vars: + developer_rust_strays_pass: all diff --git a/ansible/roles/developer-rust/tasks/rust.yml b/ansible/roles/developer-rust/tasks/rust.yml index 8fee664..6eaebcb 100644 --- a/ansible/roles/developer-rust/tasks/rust.yml +++ b/ansible/roles/developer-rust/tasks/rust.yml @@ -23,99 +23,13 @@ # ============================================================ # Where the toolchain actually lives # ============================================================ -# Everything below addresses cargo through these two facts, never through a -# hard-coded ~/.cargo. Cargo reads $CARGO_HOME/config.toml and rustup reads -# $RUSTUP_HOME, and neither is required to be under the home directory -- a -# box with a dedicated toolchain volume relocates both. Writing to ~/.cargo on -# such a host produces a file the tool silently ignores while a stale one stays -# in effect, which is a failure mode with no error message anywhere in it. -# -# An explicit role variable, else what the target user's login environment -# exports (probed in the developer role's init.yml), else the upstream default, -# so nothing downstream is ever templated blank. -- name: Resolve the effective cargo and rustup homes - ansible.builtin.set_fact: - developer_rust_cargo_home: >- - {{ rust_cargo_home | default(hyperi_host_cargo_home | default(''), true) - | default(user_home ~ '/.cargo', true) | regex_replace('/+$', '') }} - developer_rust_rustup_home: >- - {{ rust_rustup_home | default(hyperi_host_rustup_home | default(''), true) - | default(user_home ~ '/.rustup', true) | regex_replace('/+$', '') }} - tags: ['rust-cache', 'rust-governor', 'rust-llvm'] - -# Absolute, and free of the two characters that would break the root-owned -# unit file this value is templated into: `%` is a systemd specifier and `"` -# ends the quoted assignment. -- name: Check the resolved toolchain paths are usable - ansible.builtin.assert: - that: - - developer_rust_cargo_home is match('^/') - - developer_rust_rustup_home is match('^/') - - developer_rust_cargo_home is not search('[%"]') - - developer_rust_rustup_home is not search('[%"]') - fail_msg: >- - Resolved CARGO_HOME={{ developer_rust_cargo_home }}, - RUSTUP_HOME={{ developer_rust_rustup_home }} -- not an absolute path, or - contains % or ". Set rust_cargo_home/rust_rustup_home explicitly. - quiet: true - tags: ['rust-cache', 'rust-governor', 'rust-llvm'] - -# Compared by device and inode, not by path: ~/.cargo can be a symlink to the -# relocated home, and then it is the live home, not a stale one. -- name: Identify the default, the effective and the host-exported cargo homes - ansible.builtin.stat: - path: "{{ item }}" - follow: true - get_checksum: false - loop: - - "{{ user_home }}/.cargo" - - "{{ developer_rust_cargo_home }}" - - "{{ hyperi_host_cargo_home | default('', true) or '/nonexistent/hyperi-no-cargo-home' }}" - become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" - become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" - register: developer_rust_cargo_home_dirs - tags: ['rust-cache', 'rust-governor', 'rust-llvm'] - -# ~/.cargo is stale only when it is a separate directory from the effective -# home AND the host itself exports that effective home: cargo, hyperi-update and -# every shell then use it, and nothing still reads ~/.cargo. A home set only by -# rust_cargo_home is not proof -- the host's own cargo may still be ~/.cargo. -- name: Note whether ~/.cargo is a stale home beside a relocated one - ansible.builtin.set_fact: - developer_rust_cargo_home_relocated: "{{ _separate | bool }}" - developer_rust_cargo_home_stale: "{{ _separate | bool and _declared | bool }}" - vars: - _default: "{{ developer_rust_cargo_home_dirs.results[0].stat }}" - _effective: "{{ developer_rust_cargo_home_dirs.results[1].stat }}" - _host: "{{ developer_rust_cargo_home_dirs.results[2].stat }}" - _separate: >- - {{ _default.exists and _default.isdir - and not (_effective.exists and _effective.dev == _default.dev and _effective.inode == _default.inode) }} - _declared: >- - {{ _effective.exists and _host.exists - and _effective.dev == _host.dev and _effective.inode == _host.inode }} - tags: ['rust-cache', 'rust-governor', 'rust-llvm'] - -- name: Record that ~/.cargo was left alone beside an undeclared CARGO_HOME - # noqa: var-naming[no-role-prefix] -- deploy_warnings is the shared accumulator - ansible.builtin.set_fact: - deploy_warnings: >- - {{ deploy_warnings | default([]) - + ['cargo home: the role used ' ~ developer_rust_cargo_home ~ ' but the login environment of ' - ~ actual_user ~ ' exports ' ~ (hyperi_host_cargo_home | default('', true) or 'no CARGO_HOME') - ~ ', so ' ~ user_home ~ '/.cargo was neither swept nor had its config.toml retired.' - ~ ' Declare CARGO_HOME on the host (for example in /etc/environment) and re-run.'] }} - when: - - developer_rust_cargo_home_relocated | bool - - not developer_rust_cargo_home_stale | bool - tags: ['rust-cache', 'rust-governor', 'rust-llvm'] - -- name: Report where the toolchain was resolved to - ansible.builtin.debug: - msg: >- - CARGO_HOME={{ developer_rust_cargo_home }} - RUSTUP_HOME={{ developer_rust_rustup_home }} - verbosity: 1 +# Tagged rust-cache, rust-governor and rust-llvm too: each of those runs uses +# these facts and the cargo environment, and reaches here without the rest. +- name: Resolve the cargo homes and the cargo environment + ansible.builtin.include_tasks: + file: cargo_home.yml + apply: + tags: ['developer-rust', 'rust', 'rust-cache', 'rust-governor', 'rust-llvm'] tags: ['rust-cache', 'rust-governor', 'rust-llvm'] # ============================================================ @@ -128,27 +42,42 @@ # it as a missing command. when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] block: - - name: Remove distro Rust packages if present (Fedora) - ansible.builtin.dnf: - name: - - rust - - cargo - - rustfmt - - clippy - state: absent - become: true - when: ansible_facts['distribution'] == 'Fedora' + # Every package built from the distro's Rust sources, not just the four + # front-end names: Fedora's rust-std-static requires rust, so removing rust + # alone is a depsolve error, and Ubuntu's versioned rustc-1.NN packages stay + # behind the unversioned ones. Ubuntu's rust-coreutils and sudo-rs come from + # other sources and are untouched. + - name: List the installed packages by source package + ansible.builtin.command: + argv: >- + {{ ['dpkg-query', '-W', '-f', '${db:Status-Abbrev}|${Package}|${source:Package}\n'] + if ansible_facts['distribution'] == 'Ubuntu' + else ['rpm', '-qa', '--qf', 'ii |%{NAME}|%{SOURCERPM}\n'] }} + environment: {LC_ALL: C.UTF-8} + register: developer_rust_distro_query + changed_when: false + check_mode: false - - name: Remove distro Rust packages if present (Ubuntu) - ansible.builtin.apt: - name: - - rustc - - cargo - - rustfmt - - rust-clippy - state: absent - become: true - when: ansible_facts['distribution'] == 'Ubuntu' + - name: Collect the distro Rust packages + ansible.builtin.set_fact: + developer_rust_distro_packages: >- + {{ developer_rust_distro_query.stdout_lines + | select('match', ('^ii ?\|[^|]+\|(rust-defaults|rustc-[0-9.]+)$' + if ansible_facts['distribution'] == 'Ubuntu' + else '^ii ?\|[^|]+\|rust-[0-9][^|]*\.src\.rpm$')) + | map('regex_replace', '^[^|]*\|([^|]+)\|.*$', '\1') | list }} + + - name: Remove the distro Rust toolchain + ansible.builtin.include_role: + name: system_cleanup + tasks_from: purge_packages.yml + # The include is already gated by this file's tags, and its tasks carry none. + apply: + tags: ['always'] + vars: + system_cleanup_purge_packages: "{{ developer_rust_distro_packages }}" + system_cleanup_purge_reason: the distro Rust toolchain rustup replaces + when: developer_rust_distro_packages | length > 0 # rustup owns its own updates after bootstrap (`rustup update`, which # hyperi-update runs), so once ~/.cargo/bin/rustup exists there is nothing @@ -638,34 +567,6 @@ # Cargo tools installation # ============================================================ -# Tagged rust-cache and rust-llvm too: the setup script and the rustc probe in -# llvm.yml run under this environment, and an untagged set_fact leaves it -# undefined when only one of those tags is selected. -- name: Set cargo environment (Linux) - ansible.builtin.set_fact: - cargo_env: - PATH: "{{ developer_rust_cargo_home }}/bin:{{ ansible_facts['env'].PATH }}" - CARGO_HOME: "{{ developer_rust_cargo_home }}" - RUSTUP_HOME: "{{ developer_rust_rustup_home }}" - when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] - tags: ['rust-cache', 'rust-llvm'] - -# /opt/homebrew/opt/rustup/bin leads: brew's rustup never links its shims -# into /opt/homebrew/bin, so without it no cargo resolves on a rustup Mac. -- name: Set cargo environment (macOS) - ansible.builtin.set_fact: - cargo_env: - PATH: >- - {{ '/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:' - ~ developer_rust_cargo_home ~ '/bin:' ~ ansible_facts['env'].PATH }} - CARGO_HOME: "{{ developer_rust_cargo_home }}" - # Resolves to ~/.rustup when the host says nothing, which is what rustup - # would have defaulted to anyway. Carried explicitly so a Mac that - # relocates it is not the one platform still hard-coded. - RUSTUP_HOME: "{{ developer_rust_rustup_home }}" - when: ansible_facts['distribution'] == 'MacOSX' - tags: ['rust-cache', 'rust-llvm'] - # cargo-binstall is installed for the developer to reach for by hand. The tasks # below deliberately do NOT go through it: binstall fetches a publisher's # prebuilt binary, where a lockfile has no meaning, and its handling of diff --git a/ansible/roles/developer-rust/tasks/strays.yml b/ansible/roles/developer-rust/tasks/strays.yml index 0355bf4..ea907bd 100644 --- a/ansible/roles/developer-rust/tasks/strays.yml +++ b/ansible/roles/developer-rust/tasks/strays.yml @@ -15,9 +15,16 @@ # Included twice from rust.yml: the `tools` pass runs before `cargo # install-update`, so nothing it deregisters is rebuilt first. The `sccache` # pass runs after hyperi-rust-setup has put the managed sccache in place. +# removals.yml runs the `all` pass for a `--tags removals` run. +# +# Removing what the role did not install -- a duplicate, a superseded ~/.cargo +# -- is a HyperI-fleet action, so it runs only on a `removals` or `soe` run, +# like the developer role's tombstones. cargo-tarpaulin is retired on every run: +# this role installed it. - name: Name the cargo homes to sweep ansible.builtin.set_fact: + developer_rust_strays_removals: "{{ 'removals' in ansible_run_tags or 'soe' in ansible_run_tags }}" developer_rust_swept_homes: >- {{ [developer_rust_cargo_home] + ([user_home ~ '/.cargo'] if developer_rust_cargo_home_stale | default(false) | bool else []) }} @@ -30,11 +37,15 @@ # script put uv and uvx in the cargo home. sd and fnm are crates of tools the # developer role installs system-wide. - name: Remove cargo-home copies of managed tools (Linux) - when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] + when: + - ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] + - developer_rust_strays_removals | bool vars: developer_rust_managed_dupes: >- {{ [{'bin': 'sccache', 'crate': 'sccache', 'managed': ['/usr/local/bin/sccache']}] if developer_rust_strays_pass == 'sccache' else + ([{'bin': 'sccache', 'crate': 'sccache', 'managed': ['/usr/local/bin/sccache']}] + if developer_rust_strays_pass == 'all' else []) + [{'bin': 'sd', 'crate': 'sd', 'managed': ['/usr/local/bin/sd', '/usr/bin/sd']}, {'bin': 'fnm', 'crate': 'fnm', 'managed': ['/usr/local/bin/fnm']}, {'bin': 'uv', 'managed': [user_home ~ '/.local/bin/uv', '/usr/bin/uv']}, @@ -71,10 +82,11 @@ label: "{{ item.0 }}/bin/{{ item.1.bin }}" register: developer_rust_dupe_uninstall changed_when: "'Removing' in developer_rust_dupe_uninstall.stderr | default('')" - # A binary cargo has no record of is left to the file removal below. + # A binary cargo has no record of, or no cargo to run, is left to the file removal below. failed_when: >- developer_rust_dupe_uninstall.rc != 0 and 'did not match any packages' not in developer_rust_dupe_uninstall.stderr | default('') + and 'No such file' not in developer_rust_dupe_uninstall.msg | default('') when: - item.1.crate is defined - developer_rust_dupe_path in developer_rust_dupe_ids @@ -120,11 +132,12 @@ loop: "{{ developer_rust_swept_homes }}" register: developer_rust_tarpaulin_uninstall changed_when: "'Removing' in developer_rust_tarpaulin_uninstall.stderr | default('')" - # A binary cargo has no record of is left to the file removal below. + # A binary cargo has no record of, or no cargo to run, is left to the file removal below. failed_when: >- developer_rust_tarpaulin_uninstall.rc | default(0) != 0 and 'did not match any packages' not in developer_rust_tarpaulin_uninstall.stderr | default('') - when: developer_rust_strays_pass == 'tools' + and 'No such file' not in developer_rust_tarpaulin_uninstall.msg | default('') + when: developer_rust_strays_pass in ['tools', 'all'] - name: Remove the superseded cargo-tarpaulin ansible.builtin.file: @@ -133,7 +146,7 @@ become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" loop: "{{ developer_rust_swept_homes }}" - when: developer_rust_strays_pass == 'tools' + when: developer_rust_strays_pass in ['tools', 'all'] # ============================================================ # What a relocated CARGO_HOME left in ~/.cargo @@ -146,7 +159,8 @@ # installed only there stay: they may be the developer's own. - name: Remove binaries the effective cargo home supersedes when: - - developer_rust_strays_pass == 'tools' + - developer_rust_strays_pass in ['tools', 'all'] + - developer_rust_strays_removals | bool - developer_rust_cargo_home_stale | default(false) | bool vars: developer_rust_stale_names: >- @@ -231,6 +245,7 @@ failed_when: >- developer_rust_stale_uninstall.rc != 0 and 'did not match any packages' not in developer_rust_stale_uninstall.stderr | default('') + and 'No such file' not in developer_rust_stale_uninstall.msg | default('') when: - item.1.split() | length > 0 - item.1.split() | difference(developer_rust_superseded) | length == 0 diff --git a/ansible/roles/developer/files/update/hyperi-update-linux.sh b/ansible/roles/developer/files/update/hyperi-update-linux.sh index a8b3c5b..2f83f6b 100644 --- a/ansible/roles/developer/files/update/hyperi-update-linux.sh +++ b/ansible/roles/developer/files/update/hyperi-update-linux.sh @@ -37,13 +37,24 @@ set -uo pipefail # A GUI launch or a timer does not run the login shell, so a CARGO_HOME or # RUSTUP_HOME declared there is missing here. Ask the login shell for it, or # every cargo step below would act on ~/.cargo instead of the real home. -for var in CARGO_HOME RUSTUP_HOME; do - if [[ -z "${!var:-}" ]]; then - val="$(timeout 30 bash -lc "printenv $var" 2>/dev/null | tail -n 1)" - [[ -n "$val" ]] && export "$var=$val" - fi -done -unset var val +# +# The answer is read from sentinel lines, because a profile can print too, and +# only an absolute path is taken. It goes through a file rather than a pipe: a +# child a profile left in the background keeps a pipe open past the timeout. +if [[ -z "${CARGO_HOME:-}" || -z "${RUSTUP_HOME:-}" ]]; then + login_env="$(mktemp)" + # The login shell expands these, not this one. + # shellcheck disable=SC2016 + timeout 30 bash -lc 'printf "HYPERI_ENV CARGO_HOME=%s\nHYPERI_ENV RUSTUP_HOME=%s\n" "$(printenv CARGO_HOME)" "$(printenv RUSTUP_HOME)"' \ + >"$login_env" 2>/dev/null /dev/null; then fail "cargo-install-update not found in $CARGO_BIN (cargo tools are not being updated)" else skip "cargo-install-update not found (install the cargo-update crate)" @@ -674,3 +685,6 @@ else read -r -p " Press Enter to close." _ || true fi fi + +# Non-zero when any step failed, so a systemd unit or a caller sees it. +[[ ${#FAILURES[@]} -eq 0 ]] || exit 1 diff --git a/ansible/roles/developer/files/update/hyperi-update-macos.sh b/ansible/roles/developer/files/update/hyperi-update-macos.sh index d06f4c1..a66ae38 100644 --- a/ansible/roles/developer/files/update/hyperi-update-macos.sh +++ b/ansible/roles/developer/files/update/hyperi-update-macos.sh @@ -376,3 +376,7 @@ else read -r "pause? Press Enter to close." || true fi fi + +# Non-zero when any step failed, so the app leaves its window open and a +# caller sees it. +(( ${#FAILURES} == 0 )) || exit 1 diff --git a/ansible/roles/developer/tasks/init.yml b/ansible/roles/developer/tasks/init.yml index c183ab5..a3a5d60 100644 --- a/ansible/roles/developer/tasks/init.yml +++ b/ansible/roles/developer/tasks/init.yml @@ -78,7 +78,8 @@ # and a systemd unit actually inherit -- an unexported shell variable is not. # # Sentinel lines, and `last`, because a profile can print ahead of the answer. -# `timeout` because a blocking profile would otherwise hang the converge. +# `timeout` on Linux because a blocking profile would otherwise hang the +# converge. A stock macOS has no `timeout`, so the Mac branch runs without one. - name: Probe the target user's exported cargo and rustup homes ansible.builtin.command: cmd: >- diff --git a/ansible/roles/developer/tasks/removals.yml b/ansible/roles/developer/tasks/removals.yml index a49ff89..7bd1c6a 100644 --- a/ansible/roles/developer/tasks/removals.yml +++ b/ansible/roles/developer/tasks/removals.yml @@ -93,9 +93,9 @@ # link points at. A bin directory that is itself a link, or that resolves # outside the user's home, is not swept. # -# Names another tool also uses are not listed: pip's yq, Ubuntu's tea, sd and -# act are different programs, and a uv or pipx tool would be restored by its -# own updater anyway. +# yq, tea, sd and act are not listed: those names are common for unrelated +# programs a user installs, such as the pip/pipx yq, and a pipx or uv tool would +# be restored by its own updater anyway. # # Cargo homes are left to developer-rust, which knows the effective CARGO_HOME # and deregisters a cargo-installed copy so `cargo install-update` does not diff --git a/ansible/roles/system_cleanup/tasks/purge_packages.yml b/ansible/roles/system_cleanup/tasks/purge_packages.yml index 69c1150..87dfa0d 100644 --- a/ansible/roles/system_cleanup/tasks/purge_packages.yml +++ b/ansible/roles/system_cleanup/tasks/purge_packages.yml @@ -1,6 +1,6 @@ --- -# Remove system_cleanup_purge_packages without taking anything else with it, now or -# later. +# Remove system_cleanup_purge_packages without taking anything else with it, now +# or later. # # Now: a removal that would also take a package outside the list -- something # depends on one of them -- is refused and reported instead. @@ -8,8 +8,12 @@ # Later: dependencies only these packages needed become orphans, and # hyperi-update runs `apt-get autoremove` / `dnf autoremove` unattended, which # would then remove them -- gcc, binutils and libc6-dev among them for the -# distro Go. Anything the removal would newly orphan is marked manually -# installed first, so it stays until someone removes it on purpose. +# distro Go. Each new orphan the packages depend on directly is marked +# manually installed first, which also keeps everything it depends on. +# +# Every command whose output is parsed runs in the C locale, because dnf +# translates the table headers read below. Any plan that cannot be read +# exactly stops the removal rather than removing unmarked. # # Inputs: system_cleanup_purge_packages (installed package names) and # system_cleanup_purge_reason (why they go, for the report). @@ -19,20 +23,16 @@ argv: >- {{ (['apt-get', '-s', 'purge'] if ansible_facts['distribution'] == 'Ubuntu' else ['rpm', '-e', '--test']) + system_cleanup_purge_packages }} + environment: {LC_ALL: C.UTF-8, LANGUAGE: ""} become: true register: system_cleanup_purge_sim changed_when: false failed_when: false check_mode: false -# apt names a dependant in the plan; rpm refuses outright. +# apt names a dependant in the plan, and rpm refuses outright. - name: Note what else the removal would take, for {{ system_cleanup_purge_reason }} ansible.builtin.set_fact: - system_cleanup_purge_blocked: >- - {{ system_cleanup_purge_sim.rc != 0 - or (system_cleanup_purge_sim.stdout_lines | default([]) | select('match', '^(Purg|Remv) ') - | map('regex_replace', '^\S+ (\S+).*$', '\1') | reject('in', system_cleanup_purge_packages) - | list | length > 0) }} system_cleanup_purge_dependants: >- {{ system_cleanup_purge_sim.stdout_lines | default([]) | select('match', '^(Purg|Remv) ') | map('regex_replace', '^\S+ (\S+).*$', '\1') | reject('in', system_cleanup_purge_packages) | list }} @@ -45,39 +45,76 @@ if system_cleanup_purge_dependants else ('the package manager refused the removal: ' ~ system_cleanup_purge_sim.stderr | default('') | trim) }} - when: system_cleanup_purge_blocked | bool + when: system_cleanup_purge_sim.rc != 0 or system_cleanup_purge_dependants | length > 0 - name: Remove the packages, {{ system_cleanup_purge_reason }} - when: not system_cleanup_purge_blocked | bool + when: + - system_cleanup_purge_sim.rc == 0 + - system_cleanup_purge_dependants | length == 0 block: - name: List the packages already orphaned ansible.builtin.command: argv: >- {{ ['apt-get', '-s', 'autoremove'] if ansible_facts['distribution'] == 'Ubuntu' else ['dnf', '-q', 'repoquery', '--unneeded', '--qf', '%{name}\n'] }} + environment: {LC_ALL: C.UTF-8, LANGUAGE: ""} become: true register: system_cleanup_purge_orphans_before changed_when: false failed_when: false check_mode: false + # dnf ends a plan it is told not to run with rc 1 and "Operation aborted". - name: List the packages the removal would orphan ansible.builtin.command: argv: >- {{ (['apt-get', '-s', 'purge', '--autoremove'] if ansible_facts['distribution'] == 'Ubuntu' else ['dnf', 'remove', '--assumeno']) + system_cleanup_purge_packages }} + environment: {LC_ALL: C.UTF-8, LANGUAGE: ""} become: true register: system_cleanup_purge_orphans_after changed_when: false failed_when: false check_mode: false + # Hard and soft dependencies alike: apt keeps what a manual package + # recommends, and dnf's weak dependencies are its recommends. dnf takes one + # --providers-of per query, so it asks twice. + - name: List what the packages depend on directly + ansible.builtin.command: + argv: >- + {{ (['apt-cache', 'depends', '--installed', '--no-suggests', '--no-conflicts', + '--no-breaks', '--no-replaces', '--no-enhances'] + if ansible_facts['distribution'] == 'Ubuntu' + else ['dnf', '-q', 'repoquery', '--installed', '--providers-of=' ~ system_cleanup_purge_dep_kind, '--qf', '%{name}\n']) + + system_cleanup_purge_packages }} + loop: "{{ ['depends'] if ansible_facts['distribution'] == 'Ubuntu' else ['requires', 'recommends'] }}" + loop_control: + loop_var: system_cleanup_purge_dep_kind + environment: {LC_ALL: C.UTF-8, LANGUAGE: ""} + become: true + register: system_cleanup_purge_direct + changed_when: false + failed_when: false + check_mode: false + # apt: the Remv/Purg lines of each plan. dnf: the "Removing unused - # dependencies" table of the plan, and the bare names of the query. - - name: Name the packages the removal newly orphans + # dependencies" table of the plan, checked against its summary count. + - name: Name the direct dependencies the removal newly orphans ansible.builtin.set_fact: system_cleanup_purge_new_orphans: >- - {{ (_after | reject('in', _before) | reject('in', system_cleanup_purge_packages) | list) }} + {{ _after | reject('in', _before) | reject('in', system_cleanup_purge_packages) + | select('in', _direct) | list }} + system_cleanup_purge_plan_read: >- + {{ system_cleanup_purge_orphans_before.rc == 0 + and system_cleanup_purge_direct.results | rejectattr('rc', 'eq', 0) | list | length == 0 + and (system_cleanup_purge_orphans_after.rc == 0 + if ansible_facts['distribution'] == 'Ubuntu' + else (system_cleanup_purge_orphans_after.rc == 1 + and 'Operation aborted' in system_cleanup_purge_orphans_after.stdout + ~ system_cleanup_purge_orphans_after.stderr + and _summary | length == 1 + and _summary[0] | int == (system_cleanup_purge_packages | length) + (_after | length))) }} vars: _before: >- {{ (system_cleanup_purge_orphans_before.stdout_lines | default([]) | select('match', '^(Purg|Remv) ') @@ -91,15 +128,35 @@ else ((system_cleanup_purge_orphans_after.stdout | default('') | regex_findall('(?s)Removing unused dependencies:\n(.*?)(?:\n\S|\n\n|$)') | first | default('')) .splitlines() | map('trim') | select | map('regex_replace', ' .*$', '') | list) }} + _summary: >- + {{ system_cleanup_purge_orphans_after.stdout | default('') + | regex_findall('Transaction Summary:\s*\n\s*Removing:\s+(\d+) package') }} + _direct: >- + {{ (system_cleanup_purge_direct.results | map(attribute='stdout_lines') | flatten + | select('match', '^\s*\|?(Depends|PreDepends|Recommends): [^<]') + | map('regex_replace', '^.*: (\S+).*$', '\1') | list) + if ansible_facts['distribution'] == 'Ubuntu' + else (system_cleanup_purge_direct.results | map(attribute='stdout_lines') | flatten + | map('trim') | select | list) }} + + - name: Report a removal stopped because its plan could not be read, {{ system_cleanup_purge_reason }} + ansible.builtin.debug: + msg: >- + Kept {{ system_cleanup_purge_packages | join(', ') }} ({{ system_cleanup_purge_reason }}): + the package manager's plan for it could not be read, so what it would + orphan is unknown. + when: not system_cleanup_purge_plan_read | bool - - name: Keep the packages the removal would orphan + - name: Keep the direct dependencies the removal would orphan ansible.builtin.command: argv: >- {{ (['apt-mark', 'manual'] if ansible_facts['distribution'] == 'Ubuntu' else ['dnf', '-y', 'mark', 'user']) + system_cleanup_purge_new_orphans }} become: true changed_when: true - when: system_cleanup_purge_new_orphans | length > 0 + when: + - system_cleanup_purge_plan_read | bool + - system_cleanup_purge_new_orphans | length > 0 - name: Purge on Ubuntu, {{ system_cleanup_purge_reason }} ansible.builtin.apt: @@ -108,7 +165,9 @@ purge: true autoremove: false become: true - when: ansible_facts['distribution'] == 'Ubuntu' + when: + - system_cleanup_purge_plan_read | bool + - ansible_facts['distribution'] == 'Ubuntu' - name: Remove on Fedora, {{ system_cleanup_purge_reason }} ansible.builtin.dnf: @@ -116,4 +175,6 @@ state: absent autoremove: false become: true - when: ansible_facts['distribution'] == 'Fedora' + when: + - system_cleanup_purge_plan_read | bool + - ansible_facts['distribution'] == 'Fedora' diff --git a/docs/install-matrix.md b/docs/install-matrix.md index 6e865c9..c7e77aa 100644 --- a/docs/install-matrix.md +++ b/docs/install-matrix.md @@ -611,7 +611,7 @@ to -- not merely that the new thing installed. **A hand install leaves a second copy too.** The `removals` tag clears copies of the managed tools in `~/.local/bin` and `~/go/bin` where the managed copy exists and is a different file. A deliberate pin there (an older kubectl, golangci-lint v1) goes too, so pin per project instead. Tools whose name another program also uses (yq, tea, sd, act) are left alone. It also purges the upstream .deb or .rpm of macbash, git-scrub, dive, golangci-lint or k9s installed beside the `/usr/local/bin` copy, unless a repository offers a package of that name. -developer-go removes the distro Go once a working `/usr/local/go` is in and links `go` and `gofmt` into `/usr/local/bin`. developer-rust clears cargo-home duplicates, cargo-tarpaulin and, where the host exports a relocated `CARGO_HOME`, the old `~/.cargo/bin` binaries the effective home also holds. A package something else depends on stays, and the run says which. Packages a purge would orphan are marked manually installed, so `hyperi-update`'s autoremove does not take them later. +developer-go links `go` and `gofmt` into `/usr/local/bin` on every run, and on a `removals` or `soe` run also removes the distro Go once a working, self-contained `/usr/local/go` is in. developer-rust retires cargo-tarpaulin on every run, and on a `removals` or `soe` run clears cargo-home duplicates and, where the host exports a relocated `CARGO_HOME`, the old `~/.cargo/bin` binaries the effective home also holds. A package something else depends on stays, and the run says which. Its direct dependencies that a purge would orphan are marked manually installed, so `hyperi-update`'s autoremove does not take them later. ## Auto-update From 39fb05beafab6aa184ee97b028821e8bc184d4a3 Mon Sep 17 00:00:00 2001 From: Derek Date: Tue, 6 Oct 2026 18:54:02 +1100 Subject: [PATCH 4/5] docs: fix the molecule vars.yml path --- ansible/molecule/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/molecule/README.md b/ansible/molecule/README.md index 2ac60d4..06133da 100644 --- a/ansible/molecule/README.md +++ b/ansible/molecule/README.md @@ -52,7 +52,7 @@ quota get rate-limited -- a hard assertion would fail on GitHub's limiter rather than on the playbook. The rescue path turns those into `deploy_warnings`, which is the designed behaviour. -`../vars.yml` is the SSoT for WHICH releases are supported. Two files +`vars.yml` is the SSoT for WHICH releases are supported. Two files necessarily restate it -- this scenario's platform list, because molecule cannot include another YAML file, and the OS gate's `min_fedora_version` / `min_ubuntu_version`. `tools/check_release_matrix.py` runs in the test gate and From c440b52efdbeed634443b8f34194438cc8bd35ca Mon Sep 17 00:00:00 2001 From: Derek Date: Tue, 6 Oct 2026 19:28:39 +1100 Subject: [PATCH 5/5] fix: keep purge orphans when dnf hides them A host dnf config with clean_requirements_on_remove=False drops the orphan table from the removal plan. The count check then passed with nothing marked, so gcc was left for autoremove. The plan now forces the setting on. Packages the run keeps, because something depends on them or the plan could not be read, are reported at the end of the run, whose heading now covers both cases. --- ansible/playbooks/main.yml | 11 ++++--- .../files/update/hyperi-update-macos.sh | 3 +- .../system_cleanup/tasks/purge_packages.yml | 33 +++++++++++-------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/ansible/playbooks/main.yml b/ansible/playbooks/main.yml index ad365c7..b97e62a 100644 --- a/ansible/playbooks/main.yml +++ b/ansible/playbooks/main.yml @@ -367,17 +367,18 @@ - name: Report what did not install ansible.builtin.debug: msg: | - {{ deploy_warnings | length }} thing(s) did not install. + {{ deploy_warnings | length }} thing(s) need attention. - The rest of the environment is set up and usable. These are optional - components whose upstream failed; the run continued deliberately. + The rest of the environment is set up and usable. Each item below is + an optional component that did not install, or something the run left + in place because changing it was not safe. {% for w in deploy_warnings %} - {{ w }} {% endfor %} - Re-run to retry them. If one keeps failing, its upstream has probably - moved -- that is a bug in this repo, please report it. + Re-run to retry a failed install. If one keeps failing, its upstream + has probably moved -- that is a bug in this repo, please report it. when: - deploy_warnings is defined - deploy_warnings | length > 0 diff --git a/ansible/roles/developer/files/update/hyperi-update-macos.sh b/ansible/roles/developer/files/update/hyperi-update-macos.sh index a66ae38..f3babae 100644 --- a/ansible/roles/developer/files/update/hyperi-update-macos.sh +++ b/ansible/roles/developer/files/update/hyperi-update-macos.sh @@ -377,6 +377,5 @@ else fi fi -# Non-zero when any step failed, so the app leaves its window open and a -# caller sees it. +# Non-zero when any step failed, so a calling script or scheduler sees it. (( ${#FAILURES} == 0 )) || exit 1 diff --git a/ansible/roles/system_cleanup/tasks/purge_packages.yml b/ansible/roles/system_cleanup/tasks/purge_packages.yml index 87dfa0d..9a34eb6 100644 --- a/ansible/roles/system_cleanup/tasks/purge_packages.yml +++ b/ansible/roles/system_cleanup/tasks/purge_packages.yml @@ -38,13 +38,14 @@ | map('regex_replace', '^\S+ (\S+).*$', '\1') | reject('in', system_cleanup_purge_packages) | list }} - name: Report a package kept because something depends on it, {{ system_cleanup_purge_reason }} - ansible.builtin.debug: - msg: >- - Kept {{ system_cleanup_purge_packages | join(', ') }} ({{ system_cleanup_purge_reason }}): - {{ ('removing it would also remove ' ~ system_cleanup_purge_dependants | join(', ')) - if system_cleanup_purge_dependants - else ('the package manager refused the removal: ' - ~ system_cleanup_purge_sim.stderr | default('') | trim) }} + ansible.builtin.set_fact: + deploy_warnings: >- + {{ (deploy_warnings | default([]) + + ['Kept ' ~ system_cleanup_purge_packages | join(', ') ~ ' (' ~ system_cleanup_purge_reason ~ '): ' + ~ (('removing it would also remove ' ~ system_cleanup_purge_dependants | join(', ')) + if system_cleanup_purge_dependants + else ('the package manager refused the removal: ' + ~ system_cleanup_purge_sim.stderr | default('') | trim))]) | unique }} when: system_cleanup_purge_sim.rc != 0 or system_cleanup_purge_dependants | length > 0 - name: Remove the packages, {{ system_cleanup_purge_reason }} @@ -64,12 +65,15 @@ failed_when: false check_mode: false - # dnf ends a plan it is told not to run with rc 1 and "Operation aborted". + # dnf ends a plan it is told not to run with rc 1 and "Operation aborted", + # and lists orphans only with clean_requirements_on_remove, which a host's + # dnf config can turn off. - name: List the packages the removal would orphan ansible.builtin.command: argv: >- {{ (['apt-get', '-s', 'purge', '--autoremove'] if ansible_facts['distribution'] == 'Ubuntu' - else ['dnf', 'remove', '--assumeno']) + system_cleanup_purge_packages }} + else ['dnf', 'remove', '--assumeno', '--setopt=clean_requirements_on_remove=True']) + + system_cleanup_purge_packages }} environment: {LC_ALL: C.UTF-8, LANGUAGE: ""} become: true register: system_cleanup_purge_orphans_after @@ -140,11 +144,12 @@ | map('trim') | select | list) }} - name: Report a removal stopped because its plan could not be read, {{ system_cleanup_purge_reason }} - ansible.builtin.debug: - msg: >- - Kept {{ system_cleanup_purge_packages | join(', ') }} ({{ system_cleanup_purge_reason }}): - the package manager's plan for it could not be read, so what it would - orphan is unknown. + ansible.builtin.set_fact: + deploy_warnings: >- + {{ (deploy_warnings | default([]) + + ['Kept ' ~ system_cleanup_purge_packages | join(', ') ~ ' (' ~ system_cleanup_purge_reason ~ '):' + ~ " the package manager's plan for it could not be read, so what it would orphan is unknown."]) + | unique }} when: not system_cleanup_purge_plan_read | bool - name: Keep the direct dependencies the removal would orphan