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 diff --git a/ansible/molecule/remediation/molecule.yml b/ansible/molecule/remediation/molecule.yml index 5f3d545..c681099 100644 --- a/ansible/molecule/remediation/molecule.yml +++ b/ansible/molecule/remediation/molecule.yml @@ -33,12 +33,26 @@ 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 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 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 + - 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 97344d0..9591521 100644 --- a/ansible/molecule/remediation/prepare.yml +++ b/ansible/molecule/remediation/prepare.yml @@ -370,6 +370,331 @@ 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 + - yq + - golangci-lint + + - 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 + - .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, + # and a dangling one shadows nothing. + - name: Plant user-level links + ansible.builtin.file: + 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, 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', 'kind', 'dive'] | 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: kind, depends: ''} + - {name: dive, depends: ''} + + - 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 + - kind + - dive + + - 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 + - kind + - dive + + - name: Install the hand-installed fixture packages + ansible.builtin.apt: + deb: "/root/dup-fixture/{{ item }}.deb" + loop: + - macbash + - git-scrub + - git-scrub-dependant + - kind + + # 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 + + # 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 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' }}" + state: present + + - 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' 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" + + - name: Link /usr/local/go into the distro Go tree + ansible.builtin.file: + src: "{{ prepare_distro_go.stdout | dirname | dirname }}" + dest: /usr/local/go + 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: + content: | + 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: '0644' + # 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..cac2283 100644 --- a/ansible/molecule/remediation/verify.yml +++ b/ansible/molecule/remediation/verify.yml @@ -264,6 +264,172 @@ 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} + - {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 + + - name: Assert user-level duplicates went and everything else stayed + ansible.builtin.assert: + that: + - (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, 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: + 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/bin/yq + - /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 + ansible.builtin.package_facts: + manager: auto + when: ansible_facts['distribution'] == '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, 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 + ansible.builtin.assert: + that: + - verify_installed | select('match', item) | list | length == 0 + fail_msg: "{{ item }} is still installed beside the managed copy it duplicates." + success_msg: "{{ item }} removed" + loop: >- + {{ ['^macbash$'] + + ([] if inventory_hostname is search('golink|gosrc') else + (['^golang-go$', '^golang-src$', '^golang-[0-9.]+-go$'] + if ansible_facts['distribution'] == 'Ubuntu' else [])) }} + + - name: Assert the packages that must stay are still installed + ansible.builtin.assert: + that: + - item in verify_installed + fail_msg: >- + {{ 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 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) + 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/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/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 ce3562d..fd9d52d 100644 --- a/ansible/roles/contributor/tasks/hyperi_ci.yml +++ b/ansible/roles/contributor/tasks/hyperi_ci.yml @@ -103,19 +103,25 @@ # 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, 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: "{{ 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 }}" - 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': 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 f5dd172..e7ef5df 100644 --- a/ansible/roles/contributor/tasks/typos.yml +++ b/ansible/roles/contributor/tasks/typos.yml @@ -14,14 +14,20 @@ environment: "{{ homebrew_env }}" when: ansible_facts['distribution'] == 'MacOSX' +# 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: "{{ 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: - PATH: "{{ user_home }}/.cargo/bin:/usr/local/bin:{{ ansible_facts['env'].PATH }}" + environment: >- + {{ 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 fd23f4d..bce8732 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(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 - semgrep diff --git a/ansible/roles/developer-go/tasks/go.yml b/ansible/roles/developer-go/tasks/go.yml index 51b10ea..e3b062d 100644 --- a/ansible/roles/developer-go/tasks/go.yml +++ b/ansible/roles/developer-go/tasks/go.yml @@ -104,6 +104,54 @@ group: root mode: '0644' + # 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 }}" + follow: false + loop: [go, gofmt] + register: developer_go_bin_links + + - 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.0.item }}" + dest: "/usr/local/bin/{{ item.0.item }}" + state: link + force: true + loop: "{{ developer_go_bin_links.results | zip(developer_go_bin_targets.results) | list }}" + loop_control: + 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: name: delve diff --git a/ansible/roles/developer-go/tasks/main.yml b/ansible/roles/developer-go/tasks/main.yml index c17c305..079e8b0 100644 --- a/ansible/roles/developer-go/tasks/main.yml +++ b/ansible/roles/developer-go/tasks/main.yml @@ -7,3 +7,17 @@ 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. +# 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'] + - "'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 new file mode 100644 index 0000000..7597650 --- /dev/null +++ b/ansible/roles/developer-go/tasks/superseded.yml @@ -0,0 +1,111 @@ +--- +# 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 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, `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. +# +# 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 + - /usr/local/go/src/runtime + register: developer_go_resolved + changed_when: false + 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 + - 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. + - 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 + - developer_go_owned is match('^golang') + vars: + 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, + 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)) + | select('match', '^golang') | 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 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 d309b7e..a223132 100644 --- a/ansible/roles/developer-rust/README.md +++ b/ansible/roles/developer-rust/README.md @@ -166,13 +166,16 @@ 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.** 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.** 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) +- 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`. + +`~/.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. @@ -231,10 +234,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/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 847878a..6eaebcb 100644 --- a/ansible/roles/developer-rust/tasks/rust.yml +++ b/ansible/roles/developer-rust/tasks/rust.yml @@ -23,95 +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. -# -# 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. -- name: Resolve the effective cargo and rustup homes - ansible.builtin.set_fact: - developer_rust_cargo_home: >- - {{ rust_cargo_home | default(_probed_cargo, true) - | default(user_home ~ '/.cargo', true) | regex_replace('/+$', '') }} - developer_rust_rustup_home: >- - {{ rust_rustup_home | default(_probed_rustup, 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 -# 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'] - -- 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'] # ============================================================ @@ -124,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 @@ -177,8 +110,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 @@ -634,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 @@ -730,16 +635,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 @@ -789,6 +684,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. @@ -897,6 +804,17 @@ when: developer_rust_setup.rc | default(0) != 0 tags: ['rust-cache'] +# 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'] + # ============================================================ # Post-condition: the toolchain the converge just left behind works # ============================================================ @@ -988,7 +906,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..ea907bd --- /dev/null +++ b/ansible/roles/developer-rust/tasks/strays.yml @@ -0,0 +1,259 @@ +--- +# 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 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. +# 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 []) }} + +# ============================================================ +# 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'] + - 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']}, + {'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 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 find exit non-zero and drop out of the output. + - name: Identify the managed tools and their cargo-home copies + ansible.builtin.command: + 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 + 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('')" + # 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 + - 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('')" + # 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('') + 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: + 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 }}" + when: developer_rust_strays_pass in ['tools', 'all'] + +# ============================================================ +# 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, 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_strays_pass in ['tools', 'all'] + - developer_rust_strays_removals | bool + - 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: + # 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: file + become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" + 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 + 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_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: 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 }}" + loop: >- + {{ developer_rust_stale_record.stdout | default('') + | regex_findall('(?m)^(\S+) v[^\n]*:\n((?:[ \t]+\S+(?:\n|\Z))*)') }} + loop_control: + 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('') + 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 + + - 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 3ec92e3..2f83f6b 100644 --- a/ansible/roles/developer/files/update/hyperi-update-linux.sh +++ b/ansible/roles/developer/files/update/hyperi-update-linux.sh @@ -34,10 +34,34 @@ 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. +# +# 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)" fi @@ -397,11 +427,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 +459,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 +480,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 +501,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)" @@ -647,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 a7b5e3f..f3babae 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:$HOME/go/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" ASSUME_YES=0 @@ -376,3 +376,6 @@ else read -r "pause? Press Enter to close." || true fi fi + +# Non-zero when any step failed, so a calling script or scheduler sees it. +(( ${#FAILURES} == 0 )) || exit 1 diff --git a/ansible/roles/developer/tasks/init.yml b/ansible/roles/developer/tasks/init.yml index fe648b5..a3a5d60 100644 --- a/ansible/roles/developer/tasks/init.yml +++ b/ansible/roles/developer/tasks/init.yml @@ -68,6 +68,53 @@ 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` 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: >- + {{ '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 4d98548..7bd1c6a 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. @@ -77,6 +81,242 @@ 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 deliberate pin +# of a managed tool there (an older kubectl, golangci-lint v1) goes too. +# +# 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. +# +# 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 +# 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: + - 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_paths: >- + {{ (['/usr/local/bin', '/usr/bin'] + developer_user_bin_dirs) + | 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: + - 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: "{{ ['find', '-L'] + developer_tool_paths + ['-maxdepth', '0', '-type', 'f', '-printf', '%D:%i %p\\n'] }}" + 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.item }}/{{ item.1 }}" + state: absent + become: true + become_user: "{{ actual_user }}" + loop: "{{ developer_user_bin_dir_real.results | product(developer_managed_tools) | list }}" + loop_control: + label: "{{ item.0.item.item }}/{{ item.1 }}" + when: + - 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.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] }} + +# 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_hand_names }}" + register: developer_hand_local_real + changed_when: false + failed_when: false + check_mode: false + when: + - ('/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_hand_local_real.results }}" + loop_control: + label: "{{ item.item }}" + register: developer_hand_owner + changed_when: false + failed_when: false + check_mode: false + when: + - item.rc | default(1) == 0 + - item.stdout.startswith('/usr/local/') + + # 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.item.item] }} + loop: "{{ developer_hand_owner.results }}" + loop_control: + label: "{{ item.item.item }}" + register: developer_hand_origin + changed_when: false + failed_when: false + check_mode: false + when: + - item.rc | default(1) == 0 + - 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.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 + - 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. # ============================================================================ @@ -116,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/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/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..9a34eb6 --- /dev/null +++ b/ansible/roles/system_cleanup/tasks/purge_packages.yml @@ -0,0 +1,185 @@ +--- +# 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. 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). + +- 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 }} + 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, and rpm refuses outright. +- name: Note what else the removal would take, for {{ system_cleanup_purge_reason }} + ansible.builtin.set_fact: + 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.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 }} + 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", + # 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', '--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 + 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, 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) + | 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) ') + | 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) }} + _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.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 + 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_plan_read | bool + - 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: + - system_cleanup_purge_plan_read | bool + - 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: + - system_cleanup_purge_plan_read | bool + - ansible_facts['distribution'] == 'Fedora' diff --git a/docs/install-matrix.md b/docs/install-matrix.md index 8827884..c7e77aa 100644 --- a/docs/install-matrix.md +++ b/docs/install-matrix.md @@ -609,6 +609,10 @@ 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` 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 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 Every tool stays current; the mechanism depends on its channel. Three tiers: