From 266154c7ad3137de8e54efff42ebef57f26635dd Mon Sep 17 00:00:00 2001 From: Derek Date: Tue, 6 Oct 2026 12:25:36 +1100 Subject: [PATCH 1/2] fix(developer-rust): add LLVM, PGO and BOLT tools developer-rust now installs clang, lld, BOLT and the LLVM dev files as standard, so cross-language LTO, bindgen, PGO and post-link optimisation work on every Rust box without hand setup. Re-apply just this part with --tags rust-llvm. rust_llvm_version picks the major: latest (default, the newest llvmorg release tag), rustc (the major rustc was built with) or a pinned integer. llvm-profdata and llvm-cov still come from rustup's llvm-tools component, now installed on rustup Macs too, because those must match rustc's LLVM exactly. Ubuntu takes apt.llvm.org's versioned suite only, never the unversioned dev snapshot. The key is fingerprint-checked before anything trusts it and lands in /usr/share/keyrings/llvm.gpg, the same path hyperi-ci uses, and a duplicate line for the same suite in hyperi-ci's llvm.list is dropped. /usr/local/bin gets clang, clang++, ld.lld, llvm-bolt, merge-fdata and perf2bolt links to the chosen major, ahead of hyperi-rust-setup so the cargo linker resolves to it. A real file or a foreign link at those paths is left alone and reported. Fedora installs its distro LLVM (compat packages for an older pin) and warns while it sits below the requested major. macOS installs brew's keg-only llvm and lld. Every failure lands in deploy_warnings rather than stopping the run, and the toolchain post-condition now also fails a converge whose cargo config names a linker that does not exist. --- ansible/roles/developer-rust/README.md | 34 +- .../roles/developer-rust/defaults/main.yml | 37 ++ ansible/roles/developer-rust/tasks/llvm.yml | 441 ++++++++++++++++++ ansible/roles/developer-rust/tasks/main.yml | 8 +- ansible/roles/developer-rust/tasks/rust.yml | 88 +++- install.sh | 1 + 6 files changed, 590 insertions(+), 19 deletions(-) create mode 100644 ansible/roles/developer-rust/tasks/llvm.yml diff --git a/ansible/roles/developer-rust/README.md b/ansible/roles/developer-rust/README.md index 90e0c87..910efcd 100644 --- a/ansible/roles/developer-rust/README.md +++ b/ansible/roles/developer-rust/README.md @@ -1,7 +1,7 @@ # developer-rust -Rust toolchain (rustup + components), cargo tools, and a build environment -tuned to make the edit-build-test loop bearable. +Rust toolchain (rustup + components), cargo tools, LLVM with PGO and BOLT, +and a build environment tuned to make the edit-build-test loop bearable. ## hyperi-rust-setup @@ -34,6 +34,36 @@ enables by default; those calls pass through uncached. Setting `CARGO_INCREMENTAL=1` by hand is different again - sccache then refuses the build outright. The wins show up on `--release` and on clean rebuilds. +## LLVM, PGO and BOLT + +Standard in this role, not an add-on: clang, lld, BOLT and the LLVM development files, for cross-language LTO, bindgen, PGO and post-link optimisation. Re-apply just this part with `--tags rust-llvm`. + +`rust_llvm_version` picks the major: + +- `latest` (default) -- the newest `llvmorg-N.x.y` release tag upstream. +- `rustc` -- the major the installed rustc was built with (`rustc -vV`). +- an integer, e.g. `22` -- pinned. + +Which major matters differs per tool: + +- llvm-profdata and llvm-cov must match rustc's LLVM EXACTLY, because the raw profile format changes between majors. rustup's `llvm-tools-preview` supplies matched copies, installed on every rustup host, Macs included. +- Cross-language LTO needs a system LLVM at least as new as rustc's, hence the default. +- BOLT works with any recent major. + +Nothing is exported (`LLVM_PROFDATA`, `LLVM_COV`, `LIBCLANG_PATH`): a pinned path goes stale on the next `rustup update`. + +**Ubuntu** takes apt.llvm.org's VERSIONED suite (`llvm-toolchain--`) and only that. The unversioned suite is the development snapshot, and its unversioned packages replace the archive's clang, llvm and lld box-wide. The key is checked against its published fingerprint before anything trusts it. The keyring is `/usr/share/keyrings/llvm.gpg`, the same path hyperi-ci uses, because two Signed-By values for one suite stop apt reading any source list. If hyperi-ci's `llvm.list` names the same suite, that one line is removed. + +`/usr/local/bin` gets `clang`, `clang++`, `ld.lld`, `llvm-bolt`, `merge-fdata` and `perf2bolt` pointing at `/usr/bin/-`, so `hyperi-rust-setup` names LLVM N as the cargo linker. A real file, or a link not pointing at some `/usr/bin/-`, is left alone and reported. Unversioned `bolt` on Ubuntu is the Thunderbolt daemon, so the package is always `bolt-`. + +`apt full-upgrade` (and `hyperi-update`) moves patch releases within N. A new major needs a re-converge. `rust_llvm_remove_previous: true` removes the old major's packages when it moves. + +**Fedora** installs its own LLVM; there is no third-party repo. Fedora 44 ships 22, so with `latest` (23 as of 2026-10) the run warns that cross-language LTO with a rustc on 23 will fail until Fedora catches up. PGO and coverage still work. A pinned major older than Fedora's takes the compat packages (`llvm21`, `clang21`, ...). + +**macOS** installs brew's `llvm` and `lld` (`llvm@N` and `lld@N` for a pinned older major). Both are keg-only, so Apple's clang stays the default. Homebrew builds no BOLT for macOS. + +At the end the run compares the system major with rustc's. Older warns. Newer is fine, but merge Rust profiles with rustup's llvm-profdata, not the system one. + ## Keeping the caches bounded Three caches, three mechanisms, and only one of them is ours. Apply the lot diff --git a/ansible/roles/developer-rust/defaults/main.yml b/ansible/roles/developer-rust/defaults/main.yml index 8b2c6dd..eea194d 100644 --- a/ansible/roles/developer-rust/defaults/main.yml +++ b/ansible/roles/developer-rust/defaults/main.yml @@ -34,6 +34,43 @@ rust_verify_wrapper: true # an existing config, so nothing is destroyed on a host we do not control. rust_retire_superseded_config: true +# --------------------------------------------------------------------------- +# LLVM, PGO and BOLT +# --------------------------------------------------------------------------- +# clang, lld, BOLT and the LLVM development files, for cross-language LTO, +# bindgen, PGO and post-link optimisation. rustup's llvm-tools component still +# supplies llvm-profdata and llvm-cov, because those must match rustc's own +# LLVM major exactly and the system copy need not. +rust_llvm_enabled: true + +# latest | rustc | . `latest` is the newest LLVM release tag upstream, +# `rustc` is the major the installed rustc was built with, and an integer pins +# one. Cross-language LTO needs a system LLVM at least as new as rustc's, which +# is why the default leans new. Fedora installs its distro LLVM and warns when +# that is below the major asked for. +rust_llvm_version: latest + +# apt.llvm.org's published signing key. The keyring path is the one hyperi-ci +# uses for the same repository: two different Signed-By values for one suite +# stop apt reading any source list. +rust_llvm_apt_key_fingerprint: 6084F3CF814B57C1CF12EFD515CF4D18AF4F7421 # gitleaks:allow -- public key fingerprint +rust_llvm_apt_keyring: /usr/share/keyrings/llvm.gpg + +# Unversioned names in /usr/local/bin pointing at the installed major (Ubuntu +# only; Fedora and macOS already have their own unversioned names). A real file +# or a link we did not make at one of these paths is left alone and reported. +rust_llvm_links: + - clang + - clang++ + - ld.lld + - llvm-bolt + - merge-fdata + - perf2bolt + +# Remove the previous major's packages when the resolved major moves (Ubuntu +# only). Off, because a project may still pin the older clang by name. +rust_llvm_remove_previous: false + # --------------------------------------------------------------------------- # Build cache caps # --------------------------------------------------------------------------- diff --git a/ansible/roles/developer-rust/tasks/llvm.yml b/ansible/roles/developer-rust/tasks/llvm.yml new file mode 100644 index 0000000..05a0192 --- /dev/null +++ b/ansible/roles/developer-rust/tasks/llvm.yml @@ -0,0 +1,441 @@ +--- +# LLVM, PGO and BOLT tooling for Rust builds. +# +# What rustup does not ship: clang and lld for cross-language LTO and bindgen, +# the LLVM development files, and BOLT. rustup's llvm-tools component keeps +# supplying llvm-profdata and llvm-cov, which must match rustc's LLVM major +# exactly because the raw profile format changes between majors. +# +# Ubuntu takes apt.llvm.org's VERSIONED suite only. The unversioned suite is +# the development snapshot, and its unversioned packages replace the archive's +# clang, llvm and lld box-wide. +# +# Optional: any failure here lands in deploy_warnings and the run carries on. + +- name: Install the LLVM toolchain + vars: + developer_rust_llvm_request: "{{ rust_llvm_version | string | trim | lower }}" + developer_rust_llvm_linux: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + block: + # ======================================================================== + # Which major + # ======================================================================== + + # Release tags only: `-rc` and `-init` tags would otherwise name a major + # that apt.llvm.org has no stable suite for yet. + - name: List the LLVM release tags + ansible.builtin.command: + argv: [git, ls-remote, --tags, --refs, 'https://github.com/llvm/llvm-project.git', 'refs/tags/llvmorg-*'] + delegate_to: localhost + become: false + run_once: true + register: developer_rust_llvm_tags + changed_when: false + check_mode: false + when: developer_rust_llvm_request == 'latest' + + # Read whatever the mode, because the drift check below needs it too. A host + # with no rustc yet answers nothing, which only `rustc` mode treats as fatal. + - name: Read the LLVM major rustc was built with + ansible.builtin.command: + argv: [rustc, -vV] + environment: "{{ cargo_env }}" + become: "{{ developer_rust_llvm_linux }}" + become_user: "{{ actual_user if developer_rust_llvm_linux else omit }}" + register: developer_rust_llvm_rustc + changed_when: false + failed_when: false + check_mode: false + + - name: Resolve the LLVM major to install + ansible.builtin.set_fact: + developer_rust_llvm_rustc_major: "{{ _rustc_major }}" + developer_rust_llvm_major: >- + {{ (_latest_major if developer_rust_llvm_request == 'latest' + else _rustc_major if developer_rust_llvm_request == 'rustc' + else developer_rust_llvm_request) | string }} + vars: + _rustc_major: >- + {{ developer_rust_llvm_rustc.stdout | default('') + | regex_search('(?m)^LLVM version: (\d+)', '\1') | default([''], true) | first }} + _latest_major: >- + {{ (developer_rust_llvm_tags.stdout | default('') + | regex_findall('(?m)refs/tags/llvmorg-(\d+)\.\d+\.\d+$') + | map('int') | list or [0]) | max }} + + # Fails here, before any source line or package name is templated from it. + - name: Check the resolved LLVM major is usable + ansible.builtin.assert: + that: + - developer_rust_llvm_major | string is match('^[0-9]+$') + - developer_rust_llvm_major | int >= 18 + fail_msg: >- + rust_llvm_version={{ developer_rust_llvm_request }} resolved to + '{{ developer_rust_llvm_major }}', which is not an LLVM major of 18 or + later. `rustc` needs a working rustc; `latest` needs git and GitHub. + quiet: true + + # ======================================================================== + # Ubuntu: apt.llvm.org versioned suite + # ======================================================================== + + - name: Install LLVM from apt.llvm.org (Ubuntu) + when: ansible_facts['distribution'] == 'Ubuntu' + vars: + developer_rust_llvm_codename: "{{ ansible_facts['distribution_release'] }}" + developer_rust_llvm_suite: >- + llvm-toolchain-{{ ansible_facts['distribution_release'] }}-{{ developer_rust_llvm_major }} + developer_rust_llvm_sources: /etc/apt/sources.list.d/apt-llvm-org.sources + developer_rust_llvm_key_staged: /etc/apt/keyrings/llvm-snapshot.asc.unverified + developer_rust_llvm_key_dearmored: /etc/apt/keyrings/llvm-snapshot.gpg.verified + block: + # A major that is tagged upstream is not always built for this release + # yet, and apt would otherwise fail later with a less useful message. + - name: Check apt.llvm.org publishes this major for this release (Ubuntu) + ansible.builtin.uri: + url: "https://apt.llvm.org/{{ developer_rust_llvm_codename }}/dists/{{ developer_rust_llvm_suite }}/Release" + method: HEAD + status_code: 200 + check_mode: false + + # A minimal Ubuntu ships gpgv but not gpg, and deb822_repository needs + # python3-debian; both are normally there from the developer role. + - name: Ensure gpg and python3-debian are present (Ubuntu) + ansible.builtin.apt: + name: [gpg, python3-debian] + state: present + + # hyperi-ci writes this file for the same repository. The same suite + # listed twice makes apt warn on every update, so only that suite's + # line goes; its other suites stay. + - name: Remove this suite from hyperi-ci's LLVM source list (Ubuntu) + ansible.builtin.lineinfile: + path: /etc/apt/sources.list.d/llvm.list + regexp: '^\s*deb(-src)?\s.*\s{{ developer_rust_llvm_suite | regex_escape }}(\s|$)' + state: absent + + - name: Read the previously configured LLVM suite (Ubuntu) + ansible.builtin.slurp: + src: "{{ developer_rust_llvm_sources }}" + register: developer_rust_llvm_old_sources + failed_when: false + check_mode: false + when: rust_llvm_remove_previous | bool + + - name: Ensure the apt keyring directory exists (Ubuntu) + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + owner: root + group: root + mode: '0755' + + # Downloaded under a name no apt source trusts and installed only once + # its fingerprint matches; forced, so a rejected download is replaced on + # the next run instead of kept by an If-Modified-Since 304. + - name: Download the apt.llvm.org signing key (Ubuntu) + ansible.builtin.get_url: + url: https://apt.llvm.org/llvm-snapshot.gpg.key + dest: "{{ developer_rust_llvm_key_staged }}" + mode: '0644' + force: true + + - name: Read the apt.llvm.org signing key fingerprint (Ubuntu) + ansible.builtin.command: + argv: [gpg, --show-keys, --with-colons, "{{ developer_rust_llvm_key_staged }}"] + register: developer_rust_llvm_key + changed_when: false + check_mode: false + + # Fails closed: neither the trusted keyring nor the source is written + # unless the key matches apt.llvm.org's published fingerprint. + - name: Verify the apt.llvm.org signing key fingerprint (Ubuntu) + ansible.builtin.assert: + that: developer_rust_llvm_key_fpr == rust_llvm_apt_key_fingerprint + fail_msg: >- + apt.llvm.org key fingerprint {{ developer_rust_llvm_key_fpr or 'missing' }} + does not match {{ rust_llvm_apt_key_fingerprint }} + quiet: true + vars: + developer_rust_llvm_key_fpr: >- + {{ (developer_rust_llvm_key.stdout_lines | select('match', '^fpr:') + | first | default('')).split(':')[9] | default('') }} + + # Binary keyring, matching what hyperi-ci installs at the same path. + # Written to a staging name and copied, so an unchanged key reports ok. + - name: Dearmor the verified apt.llvm.org signing key (Ubuntu) + ansible.builtin.command: + argv: + - gpg + - --batch + - --yes + - --output + - "{{ developer_rust_llvm_key_dearmored }}" + - --dearmor + - "{{ developer_rust_llvm_key_staged }}" + changed_when: false + + - name: Install the verified apt.llvm.org keyring (Ubuntu) + ansible.builtin.copy: + src: "{{ developer_rust_llvm_key_dearmored }}" + dest: "{{ rust_llvm_apt_keyring }}" + remote_src: true + owner: root + group: root + mode: '0644' + + - name: Add the apt.llvm.org versioned suite (Ubuntu) + ansible.builtin.deb822_repository: + name: apt-llvm-org + types: deb + uris: "https://apt.llvm.org/{{ developer_rust_llvm_codename }}/" + suites: "{{ developer_rust_llvm_suite }}" + components: main + architectures: "{{ hyperi_arch_deb }}" + signed_by: "{{ rust_llvm_apt_keyring }}" + state: present + + # Every package is versioned. Unversioned `bolt` is the Thunderbolt + # daemon, not LLVM's BOLT. + - name: Install the LLVM toolchain packages (Ubuntu) + ansible.builtin.apt: + name: + - "clang-{{ developer_rust_llvm_major }}" + - "clang-tools-{{ developer_rust_llvm_major }}" + - "clangd-{{ developer_rust_llvm_major }}" + - "lld-{{ developer_rust_llvm_major }}" + - "llvm-{{ developer_rust_llvm_major }}" + - "llvm-{{ developer_rust_llvm_major }}-dev" + - "llvm-{{ developer_rust_llvm_major }}-tools" + - "libclang-{{ developer_rust_llvm_major }}-dev" + - "libclang-rt-{{ developer_rust_llvm_major }}-dev" + - "bolt-{{ developer_rust_llvm_major }}" + state: present + update_cache: true + + - name: Remove the previous LLVM major's packages (Ubuntu) + ansible.builtin.apt: + name: + - "clang-{{ developer_rust_llvm_previous }}" + - "clang-tools-{{ developer_rust_llvm_previous }}" + - "clangd-{{ developer_rust_llvm_previous }}" + - "lld-{{ developer_rust_llvm_previous }}" + - "llvm-{{ developer_rust_llvm_previous }}" + - "llvm-{{ developer_rust_llvm_previous }}-dev" + - "llvm-{{ developer_rust_llvm_previous }}-tools" + - "libclang-{{ developer_rust_llvm_previous }}-dev" + - "libclang-rt-{{ developer_rust_llvm_previous }}-dev" + - "bolt-{{ developer_rust_llvm_previous }}" + state: absent + vars: + developer_rust_llvm_previous: >- + {{ developer_rust_llvm_old_sources.content | default('') | b64decode + | regex_search('(?m)^Suites:\s*llvm-toolchain-\S+-(\d+)\s*$', '\1') + | default([''], true) | first }} + when: + - rust_llvm_remove_previous | bool + - developer_rust_llvm_previous | length > 0 + - developer_rust_llvm_previous != developer_rust_llvm_major + + - name: Inspect the unversioned LLVM link paths (Ubuntu) + ansible.builtin.stat: + path: "/usr/local/bin/{{ item }}" + follow: false + loop: "{{ rust_llvm_links }}" + register: developer_rust_llvm_link_paths + + # Only an absent path or a link to some /usr/bin/- is ours + # to repoint; anything else at that path belongs to someone else. + - name: Point the unversioned LLVM names at this major (Ubuntu) + ansible.builtin.file: + src: "/usr/bin/{{ item.item }}-{{ developer_rust_llvm_major }}" + dest: "/usr/local/bin/{{ item.item }}" + state: link + loop: "{{ developer_rust_llvm_link_paths.results }}" + loop_control: + label: "{{ item.item }}" + when: >- + not item.stat.exists + or (item.stat.islnk and item.stat.lnk_target is match('^/usr/bin/\S+-[0-9]+$')) + + # noqa: var-naming[no-role-prefix] -- deploy_warnings is the shared + # accumulator the playbook reports at the end of the run. + - name: Report LLVM link paths held by something else (Ubuntu) + ansible.builtin.set_fact: + deploy_warnings: >- + {{ (deploy_warnings | default([]) + + ['LLVM toolchain: /usr/local/bin/' ~ item.item ~ ' is not a link this role' + ~ ' manages, so it was left alone and does not point at LLVM ' + ~ developer_rust_llvm_major ~ '.']) | unique }} + loop: "{{ developer_rust_llvm_link_paths.results }}" + loop_control: + label: "{{ item.item }}" + when: + - item.stat.exists + - not (item.stat.islnk and item.stat.lnk_target is match('^/usr/bin/\S+-[0-9]+$')) + + - name: Record the installed LLVM major (Ubuntu) + ansible.builtin.set_fact: + developer_rust_llvm_system_major: "{{ developer_rust_llvm_major }}" + + # ======================================================================== + # Fedora: the distro's LLVM + # ======================================================================== + # Fedora carries one current LLVM plus compat packages (llvm21, clang21, ...) + # for older majors, and no third-party repository. So a newer major than + # the distro's is a warning, and an older pinned one takes the compat set. + + - name: Install LLVM from the Fedora repositories (Fedora) + when: ansible_facts['distribution'] == 'Fedora' + block: + - name: Look up the Fedora LLVM version and compat packages (Fedora) + ansible.builtin.command: + argv: + - dnf + - repoquery + - --latest-limit=1 + - --queryformat=%{name} %{version}\n + - llvm + - "llvm{{ developer_rust_llvm_major }}" + register: developer_rust_llvm_dnf + changed_when: false + check_mode: false + + - name: Decide between the distro and compat LLVM packages (Fedora) + ansible.builtin.set_fact: + developer_rust_llvm_compat: >- + {{ developer_rust_llvm_request is match('^[0-9]+$') + and _distro_major | int > developer_rust_llvm_major | int + and ('llvm' ~ developer_rust_llvm_major) in _available }} + vars: + _lines: "{{ developer_rust_llvm_dnf.stdout_lines | select('match', '^\\S+ [0-9][0-9.]*$') | list }}" + _available: "{{ _lines | map('split', ' ') | map('first') | list }}" + _distro_major: >- + {{ (_lines | select('match', '^llvm ') | first | default('llvm 0')).split(' ')[1].split('.')[0] }} + + # llvm-bolt has no compat package; BOLT from any recent major serves. + - name: Install the LLVM toolchain packages (Fedora) + ansible.builtin.dnf: + name: "{{ _compat if developer_rust_llvm_compat | bool else _distro }}" + state: present + vars: + _distro: [llvm, clang, lld, clang-tools-extra, compiler-rt, llvm-devel, clang-devel, llvm-bolt] + _compat: + - "llvm{{ developer_rust_llvm_major }}" + - "clang{{ developer_rust_llvm_major }}" + - "lld{{ developer_rust_llvm_major }}" + - "clang{{ developer_rust_llvm_major }}-tools-extra" + - "compiler-rt{{ developer_rust_llvm_major }}" + - "llvm{{ developer_rust_llvm_major }}-devel" + - "clang{{ developer_rust_llvm_major }}-devel" + - llvm-bolt + + - name: Read the installed Fedora LLVM version (Fedora) + ansible.builtin.command: + argv: + - rpm + - -q + - --queryformat=%{VERSION} + - "{{ ('llvm' ~ developer_rust_llvm_major) if developer_rust_llvm_compat else 'llvm' }}" + register: developer_rust_llvm_rpm + changed_when: false + check_mode: false + + - name: Record the installed LLVM major (Fedora) + ansible.builtin.set_fact: + developer_rust_llvm_system_major: "{{ developer_rust_llvm_rpm.stdout | trim | split('.') | first }}" + + # ======================================================================== + # macOS: Homebrew + # ======================================================================== + # Keg-only, so nothing is linked onto PATH and Apple's clang stays the + # default; a build reaches it through $(brew --prefix llvm)/bin. Homebrew + # builds no BOLT for macOS. + + - name: Install LLVM from Homebrew (macOS) + when: ansible_facts['distribution'] == 'MacOSX' + become: false + environment: "{{ homebrew_env }}" + block: + - name: Read Homebrew's current LLVM version (macOS) + ansible.builtin.command: + argv: [brew, info, --json=v2, llvm] + register: developer_rust_llvm_brew_info + changed_when: false + check_mode: false + + - name: Choose the Homebrew LLVM formulae (macOS) + ansible.builtin.set_fact: + developer_rust_llvm_brew_formulae: >- + {{ ['llvm@' ~ developer_rust_llvm_major, 'lld@' ~ developer_rust_llvm_major] + if (developer_rust_llvm_request is match('^[0-9]+$') + and developer_rust_llvm_major | int < _brew_major | int) + else ['llvm', 'lld'] }} + vars: + _brew_major: >- + {{ (developer_rust_llvm_brew_info.stdout | from_json).formulae[0].versions.stable.split('.')[0] }} + + - name: Install the LLVM toolchain formulae (macOS) + community.general.homebrew: + name: "{{ developer_rust_llvm_brew_formulae }}" + state: present + + - name: Read the installed Homebrew LLVM version (macOS) + ansible.builtin.command: + argv: [brew, list, --versions, "{{ developer_rust_llvm_brew_formulae | first }}"] + register: developer_rust_llvm_brew_list + changed_when: false + check_mode: false + + - name: Record the installed LLVM major (macOS) + ansible.builtin.set_fact: + developer_rust_llvm_system_major: >- + {{ developer_rust_llvm_brew_list.stdout | regex_search('\s(\d+)\.', '\1') | default(['0'], true) | first }} + + # ======================================================================== + # Drift against rustc + # ======================================================================== + # Nothing is exported (no LLVM_PROFDATA, LLVM_COV or LIBCLANG_PATH): each + # tool finds its own match, and a pinned path goes stale on the next + # `rustup update`. + + # noqa: var-naming[no-role-prefix] -- deploy_warnings is the shared + # accumulator the playbook reports at the end of the run. + - name: Warn when the system LLVM is older than wanted + ansible.builtin.set_fact: + deploy_warnings: >- + {{ (deploy_warnings | default([]) + + ['LLVM toolchain: the system LLVM is ' ~ _system ~ ', older than ' + ~ ((_wanted ~ ' (rust_llvm_version=' ~ developer_rust_llvm_request ~ ')') + if _system | int < _wanted | int + else (_rustc ~ ', the LLVM this rustc was built with')) + ~ ". Cross-language LTO against a rustc built on a newer LLVM fails;" + ~ " PGO and coverage still work through rustup's llvm-tools."]) | unique }} + vars: + _system: "{{ developer_rust_llvm_system_major | default('0') }}" + _wanted: "{{ developer_rust_llvm_major }}" + _rustc: "{{ developer_rust_llvm_rustc_major | default('', true) }}" + when: >- + _system | int < _wanted | int + or (_rustc | length > 0 and _system | int < _rustc | int) + + - name: Note which llvm-profdata merges Rust profiles + ansible.builtin.debug: + msg: >- + The system LLVM ({{ developer_rust_llvm_system_major }}) is newer than + rustc's ({{ developer_rust_llvm_rustc_major }}). Merge Rust profiles with + rustup's llvm-profdata (cargo pgo does), not the system one: raw + profile formats differ between majors. + when: + - developer_rust_llvm_rustc_major | default('', true) | length > 0 + - developer_rust_llvm_system_major | default('0') | int > developer_rust_llvm_rustc_major | int + + rescue: + # noqa: var-naming[no-role-prefix] -- deploy_warnings is the shared + # accumulator the playbook reports at the end of the run. + - name: Record that the LLVM toolchain did not install + ansible.builtin.set_fact: + deploy_warnings: >- + {{ (deploy_warnings | default([]) + + ['LLVM toolchain: ' ~ (ansible_failed_task.name | default('unknown task')) + ~ ' -- ' ~ (ansible_failed_result.msg | default('no message'))]) | unique }} diff --git a/ansible/roles/developer-rust/tasks/main.yml b/ansible/roles/developer-rust/tasks/main.yml index 33e6222..8589bfa 100644 --- a/ansible/roles/developer-rust/tasks/main.yml +++ b/ansible/roles/developer-rust/tasks/main.yml @@ -4,15 +4,15 @@ # rust-cache tags only the include itself, never `apply`: a tag under `apply` # lands on every task in the file, which would make the cap tag pull the whole # toolchain. The cache include inside rust.yml carries the tag on its own. -# rust-cache and rust-governor are entry tags only: they open rust.yml so the -# matching includes inside can run, and apply: does not carry them, so neither -# pulls the whole toolchain. +# rust-cache, rust-governor and rust-llvm are entry tags only: they open rust.yml +# so the matching includes inside can run, and apply: does not carry them, so +# none of them pulls the whole toolchain. - name: Install Rust and cargo tools ansible.builtin.include_tasks: file: rust.yml apply: tags: ['developer-rust', 'rust'] - tags: ['developer-rust', 'rust', 'rust-cache', 'rust-governor'] + tags: ['developer-rust', 'rust', 'rust-cache', 'rust-governor', 'rust-llvm'] # Its own tag so the governor can be applied or re-applied without a full # toolchain converge, the same bargain rust-cache makes. diff --git a/ansible/roles/developer-rust/tasks/rust.yml b/ansible/roles/developer-rust/tasks/rust.yml index d08d10b..1159ef3 100644 --- a/ansible/roles/developer-rust/tasks/rust.yml +++ b/ansible/roles/developer-rust/tasks/rust.yml @@ -65,7 +65,7 @@ # 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'] + 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 @@ -87,7 +87,7 @@ _probed_rustup: >- {{ _lines | select('match', '^HYPERI_RUSTUP_HOME ') | list | last | default('') | regex_replace('^\S+ ', '') }} - tags: ['rust-cache', 'rust-governor'] + 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 `"` @@ -104,7 +104,7 @@ 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'] + tags: ['rust-cache', 'rust-governor', 'rust-llvm'] - name: Report where the toolchain was resolved to ansible.builtin.debug: @@ -112,7 +112,7 @@ CARGO_HOME={{ developer_rust_cargo_home }} RUSTUP_HOME={{ developer_rust_rustup_home }} verbosity: 1 - tags: ['rust-cache', 'rust-governor'] + tags: ['rust-cache', 'rust-governor', 'rust-llvm'] # ============================================================ # Rust Installation (Linux -- one path for both distros) @@ -634,8 +634,9 @@ # Cargo tools installation # ============================================================ -# Tagged rust-cache too: the setup script below runs under this environment, -# and an untagged set_fact leaves it undefined when only the cap tag is selected. +# 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: @@ -643,7 +644,7 @@ CARGO_HOME: "{{ developer_rust_cargo_home }}" RUSTUP_HOME: "{{ developer_rust_rustup_home }}" when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] - tags: ['rust-cache'] + 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. @@ -659,7 +660,7 @@ # relocates it is not the one platform still hard-coded. RUSTUP_HOME: "{{ developer_rust_rustup_home }}" when: ansible_facts['distribution'] == 'MacOSX' - tags: ['rust-cache'] + 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 @@ -711,15 +712,23 @@ become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" -- name: Install the llvm-tools component (cargo-llvm-cov dependency) +# llvm-profdata and llvm-cov matched to rustc's own LLVM, which cargo-llvm-cov +# and cargo-pgo both need. A brew-rust Mac has no rustup and no component to +# add. On macOS rustup is resolved through cargo_env's PATH, because brew's +# rustup lives outside the cargo bin directory. +- name: Install the llvm-tools component (cargo-llvm-cov and cargo-pgo dependency) ansible.builtin.command: - cmd: "{{ developer_rust_cargo_home }}/bin/rustup component add llvm-tools-preview" + cmd: >- + {{ 'rustup' if ansible_facts['distribution'] == 'MacOSX' + else developer_rust_cargo_home ~ '/bin/rustup' }} component add llvm-tools-preview become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" environment: "{{ cargo_env }}" register: developer_rust_llvm_tools changed_when: "'is up to date' not in developer_rust_llvm_tools.stderr" - when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] + when: >- + 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. @@ -815,6 +824,20 @@ | select('search', '\s+Yes\s*$') | list | length > 0 +# ============================================================ +# LLVM, PGO and BOLT +# ============================================================ +# Before hyperi-rust-setup, which names whatever `clang` resolves to as the +# cargo linker: on Ubuntu that has to be the /usr/local/bin link this installs. + +- name: Install LLVM, PGO and BOLT tooling + ansible.builtin.include_tasks: + file: llvm.yml + apply: + tags: ['developer-rust', 'rust', 'rust-llvm'] + when: rust_llvm_enabled | default(true) | bool + tags: ['developer-rust', 'rust', 'rust-llvm'] + # ============================================================ # Build acceleration: sccache + mold # ============================================================ @@ -895,9 +918,11 @@ # good config back, and pass, while cargo went on reading a stale file # somewhere else. A leftover config in the location that is NOT in effect is # exactly the trap this whole change exists to close. -- name: Verify no cargo config names an unusable rustc-wrapper +# The linker gets the same check: it is the /usr/local/bin/clang link on Ubuntu, +# so a link left pointing at a removed LLVM major breaks every build the same way. +- name: Verify no cargo config names an unusable rustc-wrapper or linker when: rust_verify_wrapper | default(true) | bool - tags: ['rust-cache'] + tags: ['rust-cache', 'rust-llvm'] block: - name: Read every candidate cargo config ansible.builtin.slurp: @@ -980,6 +1005,43 @@ | selectattr('rc', 'defined') | selectattr('rc', 'ne', 0) | list | length > 0 + # Plain and dotted keys, both quote styles, one line each -- the same + # reasoning as the rustc-wrapper extraction above. + - name: Extract the linker each config names + ansible.builtin.set_fact: + developer_rust_linkers: >- + {{ developer_rust_cargo_configs.results + | selectattr('content', 'defined') | map(attribute='content') + | map('b64decode') | join('\n') + | regex_findall('(?m)^[ \t]*(?:target\.[^=\n]+\.)?linker[ \t]*=[ \t]*["\x27]([^"\x27\n]+)["\x27]') + | unique }} + + - name: Check that each configured linker resolves + ansible.builtin.command: + argv: [bash, -lc, '[ -x "$(command -v -- "$1")" ]', bash, "{{ item }}"] + become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" + register: developer_rust_linker_checks + changed_when: false + failed_when: false + check_mode: false + loop: "{{ developer_rust_linkers }}" + + - name: Fail when a cargo config names a linker that does not exist + ansible.builtin.fail: + msg: >- + A cargo config on this host sets linker = + "{{ developer_rust_linker_checks.results + | selectattr('rc', 'defined') | selectattr('rc', 'ne', 0) + | map(attribute='item') | join('", "') }}", + which does not resolve for {{ actual_user }}, so every cargo build + fails at the link step. The effective CARGO_HOME here is + {{ developer_rust_cargo_home }}. Re-run /usr/local/bin/hyperi-rust-setup + to rewrite it from the tools actually installed. + when: developer_rust_linker_checks.results | default([]) + | selectattr('rc', 'defined') | selectattr('rc', 'ne', 0) + | list | length > 0 + # ============================================================ # Build cache caps # ============================================================ diff --git a/install.sh b/install.sh index a4d306b..095aebb 100755 --- a/install.sh +++ b/install.sh @@ -164,6 +164,7 @@ Generic dev GUI (developer-gui): Languages (developer-; --languages [list] or developer-languages for all): developer-rust rustup + cargo tools + protoc/librdkafka build deps + + LLVM: clang, lld, PGO and BOLT tooling developer-go Go + gopls, dlv, golangci-lint, gosec, govulncheck developer-python mypy (opt-in; ruff/ty ship in the base astral suite) developer-node eslint + prettier (Node itself is in the base -- it is From 20aa10598ef3e2d84d51b1e39715e5e1c46f3d49 Mon Sep 17 00:00:00 2001 From: Derek Date: Tue, 6 Oct 2026 13:14:45 +1100 Subject: [PATCH 2/2] fix(developer-rust): harden LLVM repo and key handling apt refuses to read any source list once one URI and suite carries two different Signed-By values, and upstream's llvm.sh writes exactly such a line. The role now finds every other entry for the suite it is about to add, drops the one-line ones only after its own source is written, and stands aside with a warning when someone else's deb822 file already owns the suite. A failed run puts our source and any edited list back as it found them and checks apt-get update reads cleanly, so a switch to a major whose packages are missing leaves the old major installable. The key file must now hold exactly one primary key with the pinned fingerprint, because dearmoring trusts every key in the file. The pinned value is compared without spaces and in upper case. The host linker post-condition now checks only the host target's linker and also looks in the rustc sysroot, so rust-lld and an uninstalled cross linker no longer fail a converge. The drift warning only claims cross-language LTO breaks when the system LLVM is older than rustc's; an unavailable pin gets its own warning and trailing latest is a debug note. Also: per-host latest resolution, 023 read as 23, no lld@N below 19 on macOS, the highest brew keg version, previous-major removal after the links move, no warnings from check mode, and corrected lld keg-only text. --- ansible/roles/developer-rust/README.md | 8 +- ansible/roles/developer-rust/tasks/llvm.yml | 347 +++++++++++++++----- ansible/roles/developer-rust/tasks/rust.yml | 51 ++- 3 files changed, 301 insertions(+), 105 deletions(-) diff --git a/ansible/roles/developer-rust/README.md b/ansible/roles/developer-rust/README.md index 910efcd..d309b7e 100644 --- a/ansible/roles/developer-rust/README.md +++ b/ansible/roles/developer-rust/README.md @@ -52,17 +52,17 @@ Which major matters differs per tool: Nothing is exported (`LLVM_PROFDATA`, `LLVM_COV`, `LIBCLANG_PATH`): a pinned path goes stale on the next `rustup update`. -**Ubuntu** takes apt.llvm.org's VERSIONED suite (`llvm-toolchain--`) and only that. The unversioned suite is the development snapshot, and its unversioned packages replace the archive's clang, llvm and lld box-wide. The key is checked against its published fingerprint before anything trusts it. The keyring is `/usr/share/keyrings/llvm.gpg`, the same path hyperi-ci uses, because two Signed-By values for one suite stop apt reading any source list. If hyperi-ci's `llvm.list` names the same suite, that one line is removed. +**Ubuntu** takes apt.llvm.org's VERSIONED suite (`llvm-toolchain--`) and only that. The unversioned suite is the development snapshot, and its unversioned packages replace the archive's clang, llvm and lld box-wide. The key file must hold exactly one key, with the published fingerprint, before anything trusts it. The keyring is `/usr/share/keyrings/llvm.gpg`, the same path hyperi-ci uses. Two Signed-By values for one suite stop apt reading any source list, so a one-line entry for the same suite anywhere else (hyperi-ci's `llvm.list`, upstream's `llvm.sh`) is removed once ours is written. A deb822 file someone else wrote for that suite is used as is, with a warning. A failed run puts the sources back as it found them. `/usr/local/bin` gets `clang`, `clang++`, `ld.lld`, `llvm-bolt`, `merge-fdata` and `perf2bolt` pointing at `/usr/bin/-`, so `hyperi-rust-setup` names LLVM N as the cargo linker. A real file, or a link not pointing at some `/usr/bin/-`, is left alone and reported. Unversioned `bolt` on Ubuntu is the Thunderbolt daemon, so the package is always `bolt-`. `apt full-upgrade` (and `hyperi-update`) moves patch releases within N. A new major needs a re-converge. `rust_llvm_remove_previous: true` removes the old major's packages when it moves. -**Fedora** installs its own LLVM; there is no third-party repo. Fedora 44 ships 22, so with `latest` (23 as of 2026-10) the run warns that cross-language LTO with a rustc on 23 will fail until Fedora catches up. PGO and coverage still work. A pinned major older than Fedora's takes the compat packages (`llvm21`, `clang21`, ...). +**Fedora** installs its own LLVM; there is no third-party repo. Fedora 44 ships 22 and rustc is on 23 (as of 2026-10), so the run warns that cross-language LTO with that rustc fails until Fedora catches up. PGO and coverage still work. A pinned major older than Fedora's takes the compat packages (`llvm21`, `clang21`, ...). -**macOS** installs brew's `llvm` and `lld` (`llvm@N` and `lld@N` for a pinned older major). Both are keg-only, so Apple's clang stays the default. Homebrew builds no BOLT for macOS. +**macOS** installs brew's `llvm` and `lld` (`llvm@N`, plus `lld@N` from 19 up, for a pinned older major). `llvm` is keg-only, so Apple's clang stays the default. `lld` is not: `ld.lld`, `ld64.lld` and `wasm-ld` land on PATH, which is harmless because Apple's linker is `ld`. Homebrew builds no BOLT for macOS. -At the end the run compares the system major with rustc's. Older warns. Newer is fine, but merge Rust profiles with rustup's llvm-profdata, not the system one. +At the end the run compares the system major with rustc's. Older warns. Newer is fine, but merge Rust profiles with rustup's llvm-profdata, not the system one. A pinned major the host cannot package also warns. ## Keeping the caches bounded diff --git a/ansible/roles/developer-rust/tasks/llvm.yml b/ansible/roles/developer-rust/tasks/llvm.yml index 05a0192..a9c0ef1 100644 --- a/ansible/roles/developer-rust/tasks/llvm.yml +++ b/ansible/roles/developer-rust/tasks/llvm.yml @@ -10,25 +10,42 @@ # the development snapshot, and its unversioned packages replace the archive's # clang, llvm and lld box-wide. # -# Optional: any failure here lands in deploy_warnings and the run carries on. +# Optional: any failure here lands in deploy_warnings and the run carries on, +# with the apt sources put back the way this run found them. - name: Install the LLVM toolchain vars: developer_rust_llvm_request: "{{ rust_llvm_version | string | trim | lower }}" developer_rust_llvm_linux: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + developer_rust_llvm_codename: "{{ ansible_facts['distribution_release'] }}" + developer_rust_llvm_suite: "llvm-toolchain-{{ ansible_facts['distribution_release'] }}-{{ developer_rust_llvm_major }}" + developer_rust_llvm_sources: /etc/apt/sources.list.d/apt-llvm-org.sources + developer_rust_llvm_key_url: https://apt.llvm.org/llvm-snapshot.gpg.key + developer_rust_llvm_key_staged: /etc/apt/keyrings/llvm-snapshot.asc.unverified + developer_rust_llvm_key_dearmored: /etc/apt/keyrings/llvm-snapshot.gpg.verified block: + # The role runs more than once in a play when a persona pulls it in, so the + # rescue must never act on what an earlier pass recorded. + - name: Reset the LLVM source bookkeeping for this pass + ansible.builtin.set_fact: + developer_rust_llvm_repo_written: false + developer_rust_llvm_prev: {exists: false, content: ''} + developer_rust_llvm_foreign_before: [] + developer_rust_llvm_defer_to: [] + # ======================================================================== # Which major # ======================================================================== # Release tags only: `-rc` and `-init` tags would otherwise name a major - # that apt.llvm.org has no stable suite for yet. + # that apt.llvm.org has no stable suite for yet. Per host rather than + # run_once, so a host that wants `latest` is never skipped because the + # first host in the play pinned a major. - name: List the LLVM release tags ansible.builtin.command: argv: [git, ls-remote, --tags, --refs, 'https://github.com/llvm/llvm-project.git', 'refs/tags/llvmorg-*'] delegate_to: localhost become: false - run_once: true register: developer_rust_llvm_tags changed_when: false check_mode: false @@ -47,13 +64,15 @@ failed_when: false check_mode: false + # `| int | string` turns `023` into `23` and anything that is not a number + # into `0`, which the assert below then rejects. - name: Resolve the LLVM major to install ansible.builtin.set_fact: developer_rust_llvm_rustc_major: "{{ _rustc_major }}" developer_rust_llvm_major: >- {{ (_latest_major if developer_rust_llvm_request == 'latest' else _rustc_major if developer_rust_llvm_request == 'rustc' - else developer_rust_llvm_request) | string }} + else developer_rust_llvm_request) | int | string }} vars: _rustc_major: >- {{ developer_rust_llvm_rustc.stdout | default('') @@ -66,28 +85,24 @@ # Fails here, before any source line or package name is templated from it. - name: Check the resolved LLVM major is usable ansible.builtin.assert: - that: - - developer_rust_llvm_major | string is match('^[0-9]+$') - - developer_rust_llvm_major | int >= 18 + that: developer_rust_llvm_major | int >= 18 fail_msg: >- - rust_llvm_version={{ developer_rust_llvm_request }} resolved to - '{{ developer_rust_llvm_major }}', which is not an LLVM major of 18 or - later. `rustc` needs a working rustc; `latest` needs git and GitHub. + rust_llvm_version={{ developer_rust_llvm_request }} did not resolve to an + LLVM major of 18 or later. `rustc` needs a working rustc; `latest` needs + git and GitHub. quiet: true # ======================================================================== # Ubuntu: apt.llvm.org versioned suite # ======================================================================== + # apt refuses to read ANY source list once one URI and suite carries two + # different Signed-By values (or one with and one without), so every other + # entry for this suite is found first. One-line entries are dropped, but only + # after our own source is written; a deb822 file we did not write is left + # in charge and ours stands aside. - name: Install LLVM from apt.llvm.org (Ubuntu) when: ansible_facts['distribution'] == 'Ubuntu' - vars: - developer_rust_llvm_codename: "{{ ansible_facts['distribution_release'] }}" - developer_rust_llvm_suite: >- - llvm-toolchain-{{ ansible_facts['distribution_release'] }}-{{ developer_rust_llvm_major }} - developer_rust_llvm_sources: /etc/apt/sources.list.d/apt-llvm-org.sources - developer_rust_llvm_key_staged: /etc/apt/keyrings/llvm-snapshot.asc.unverified - developer_rust_llvm_key_dearmored: /etc/apt/keyrings/llvm-snapshot.gpg.verified block: # A major that is tagged upstream is not always built for this release # yet, and apt would otherwise fail later with a less useful message. @@ -105,23 +120,6 @@ name: [gpg, python3-debian] state: present - # hyperi-ci writes this file for the same repository. The same suite - # listed twice makes apt warn on every update, so only that suite's - # line goes; its other suites stay. - - name: Remove this suite from hyperi-ci's LLVM source list (Ubuntu) - ansible.builtin.lineinfile: - path: /etc/apt/sources.list.d/llvm.list - regexp: '^\s*deb(-src)?\s.*\s{{ developer_rust_llvm_suite | regex_escape }}(\s|$)' - state: absent - - - name: Read the previously configured LLVM suite (Ubuntu) - ansible.builtin.slurp: - src: "{{ developer_rust_llvm_sources }}" - register: developer_rust_llvm_old_sources - failed_when: false - check_mode: false - when: rust_llvm_remove_previous | bool - - name: Ensure the apt keyring directory exists (Ubuntu) ansible.builtin.file: path: /etc/apt/keyrings @@ -135,7 +133,7 @@ # the next run instead of kept by an If-Modified-Since 304. - name: Download the apt.llvm.org signing key (Ubuntu) ansible.builtin.get_url: - url: https://apt.llvm.org/llvm-snapshot.gpg.key + url: "{{ developer_rust_llvm_key_url }}" dest: "{{ developer_rust_llvm_key_staged }}" mode: '0644' force: true @@ -147,19 +145,24 @@ changed_when: false check_mode: false - # Fails closed: neither the trusted keyring nor the source is written - # unless the key matches apt.llvm.org's published fingerprint. + # Exactly one primary key, and it is the pinned one: dearmoring installs + # EVERY key in the file, so a second key riding along with the real one + # would otherwise be trusted for this repository. - name: Verify the apt.llvm.org signing key fingerprint (Ubuntu) ansible.builtin.assert: - that: developer_rust_llvm_key_fpr == rust_llvm_apt_key_fingerprint + that: + - _pubs | length == 1 + - _fpr == _pinned fail_msg: >- - apt.llvm.org key fingerprint {{ developer_rust_llvm_key_fpr or 'missing' }} - does not match {{ rust_llvm_apt_key_fingerprint }} + apt.llvm.org key file holds {{ _pubs | length }} primary key(s), first + fingerprint {{ _fpr or 'missing' }}; expected exactly one, {{ _pinned }} quiet: true vars: - developer_rust_llvm_key_fpr: >- + _pubs: "{{ developer_rust_llvm_key.stdout_lines | select('match', '^pub:') | list }}" + _fpr: >- {{ (developer_rust_llvm_key.stdout_lines | select('match', '^fpr:') | first | default('')).split(':')[9] | default('') }} + _pinned: "{{ rust_llvm_apt_key_fingerprint | string | regex_replace('\\s', '') | upper }}" # Binary keyring, matching what hyperi-ci installs at the same path. # Written to a staging name and copied, so an unchanged key reports ok. @@ -184,6 +187,52 @@ group: root mode: '0644' + # Kept so a failed switch can put the previous suite back, and so + # rust_llvm_remove_previous knows which major it was. + - name: Read the apt.llvm.org source as this run found it (Ubuntu) + ansible.builtin.slurp: + src: "{{ developer_rust_llvm_sources }}" + register: developer_rust_llvm_sources_read + failed_when: false + check_mode: false + + - name: Record the apt.llvm.org source as this run found it (Ubuntu) + ansible.builtin.set_fact: + developer_rust_llvm_prev: + exists: "{{ developer_rust_llvm_sources_read.content is defined }}" + content: "{{ developer_rust_llvm_sources_read.content | default('') | b64decode }}" + + # Uncommented one-line entries and deb822 Suites lines naming this suite. + - name: Find other apt entries for this suite (Ubuntu) + ansible.builtin.find: + paths: [/etc/apt, /etc/apt/sources.list.d] + patterns: ['sources.list', '*.list', '*.sources'] + contains: '^\s*(deb(-src)?\s.*\s|Suites:(.*\s)?){{ developer_rust_llvm_suite | regex_escape }}(\s.*)?$' + register: developer_rust_llvm_others + + - name: Sort the other entries into one-line and deb822 files (Ubuntu) + ansible.builtin.set_fact: + developer_rust_llvm_defer_to: "{{ _others | select('match', '.*\\.sources$') | list }}" + developer_rust_llvm_foreign_lists: "{{ _others | reject('match', '.*\\.sources$') | list }}" + vars: + _others: >- + {{ developer_rust_llvm_others.files | map(attribute='path') + | reject('equalto', developer_rust_llvm_sources) | list }} + + - name: Snapshot the one-line lists about to be edited (Ubuntu) + ansible.builtin.slurp: + src: "{{ item }}" + loop: "{{ developer_rust_llvm_foreign_lists if developer_rust_llvm_defer_to | length == 0 else [] }}" + register: developer_rust_llvm_foreign_read + + - name: Record the one-line lists about to be edited (Ubuntu) + ansible.builtin.set_fact: + developer_rust_llvm_foreign_before: >- + {{ developer_rust_llvm_foreign_read.results | default([]) + | selectattr('content', 'defined') | list }} + + # Absent while another deb822 file owns the suite: two of them with + # different keyrings is the same box-wide failure as a stray one-liner. - name: Add the apt.llvm.org versioned suite (Ubuntu) ansible.builtin.deb822_repository: name: apt-llvm-org @@ -193,7 +242,32 @@ components: main architectures: "{{ hyperi_arch_deb }}" signed_by: "{{ rust_llvm_apt_keyring }}" - state: present + state: "{{ 'absent' if developer_rust_llvm_defer_to | length > 0 else 'present' }}" + register: developer_rust_llvm_repo + + - name: Note whether this run changed the apt.llvm.org source (Ubuntu) + ansible.builtin.set_fact: + developer_rust_llvm_repo_written: "{{ developer_rust_llvm_repo is changed }}" + + # noqa: var-naming[no-role-prefix] -- deploy_warnings is the shared + # accumulator the playbook reports at the end of the run. + - name: Report a deb822 file that already owns this suite (Ubuntu) + ansible.builtin.set_fact: + deploy_warnings: >- + {{ (deploy_warnings | default([]) + + ['LLVM toolchain: ' ~ (developer_rust_llvm_defer_to | join(', ')) + ~ ' already configures ' ~ developer_rust_llvm_suite + ~ ', so it was used as is and apt-llvm-org.sources was not written.']) + | unique }} + when: developer_rust_llvm_defer_to | length > 0 + + - name: Remove other one-line entries for this suite (Ubuntu) + ansible.builtin.lineinfile: + path: "{{ item }}" + regexp: '^\s*deb(-src)?\s.*\s{{ developer_rust_llvm_suite | regex_escape }}(\s|$)' + state: absent + loop: "{{ developer_rust_llvm_foreign_lists }}" + when: developer_rust_llvm_defer_to | length == 0 # Every package is versioned. Unversioned `bolt` is the Thunderbolt # daemon, not LLVM's BOLT. @@ -213,30 +287,6 @@ state: present update_cache: true - - name: Remove the previous LLVM major's packages (Ubuntu) - ansible.builtin.apt: - name: - - "clang-{{ developer_rust_llvm_previous }}" - - "clang-tools-{{ developer_rust_llvm_previous }}" - - "clangd-{{ developer_rust_llvm_previous }}" - - "lld-{{ developer_rust_llvm_previous }}" - - "llvm-{{ developer_rust_llvm_previous }}" - - "llvm-{{ developer_rust_llvm_previous }}-dev" - - "llvm-{{ developer_rust_llvm_previous }}-tools" - - "libclang-{{ developer_rust_llvm_previous }}-dev" - - "libclang-rt-{{ developer_rust_llvm_previous }}-dev" - - "bolt-{{ developer_rust_llvm_previous }}" - state: absent - vars: - developer_rust_llvm_previous: >- - {{ developer_rust_llvm_old_sources.content | default('') | b64decode - | regex_search('(?m)^Suites:\s*llvm-toolchain-\S+-(\d+)\s*$', '\1') - | default([''], true) | first }} - when: - - rust_llvm_remove_previous | bool - - developer_rust_llvm_previous | length > 0 - - developer_rust_llvm_previous != developer_rust_llvm_major - - name: Inspect the unversioned LLVM link paths (Ubuntu) ansible.builtin.stat: path: "/usr/local/bin/{{ item }}" @@ -274,6 +324,31 @@ - item.stat.exists - not (item.stat.islnk and item.stat.lnk_target is match('^/usr/bin/\S+-[0-9]+$')) + # After the links move, so nothing ever points at a removed major. + - name: Remove the previous LLVM major's packages (Ubuntu) + ansible.builtin.apt: + name: + - "clang-{{ developer_rust_llvm_previous }}" + - "clang-tools-{{ developer_rust_llvm_previous }}" + - "clangd-{{ developer_rust_llvm_previous }}" + - "lld-{{ developer_rust_llvm_previous }}" + - "llvm-{{ developer_rust_llvm_previous }}" + - "llvm-{{ developer_rust_llvm_previous }}-dev" + - "llvm-{{ developer_rust_llvm_previous }}-tools" + - "libclang-{{ developer_rust_llvm_previous }}-dev" + - "libclang-rt-{{ developer_rust_llvm_previous }}-dev" + - "bolt-{{ developer_rust_llvm_previous }}" + state: absent + vars: + developer_rust_llvm_previous: >- + {{ developer_rust_llvm_prev.content + | regex_search('(?m)^Suites:\s*llvm-toolchain-\S+-(\d+)\s*$', '\1') + | default([''], true) | first }} + when: + - rust_llvm_remove_previous | bool + - developer_rust_llvm_previous | length > 0 + - developer_rust_llvm_previous != developer_rust_llvm_major + - name: Record the installed LLVM major (Ubuntu) ansible.builtin.set_fact: developer_rust_llvm_system_major: "{{ developer_rust_llvm_major }}" @@ -283,7 +358,8 @@ # ======================================================================== # Fedora carries one current LLVM plus compat packages (llvm21, clang21, ...) # for older majors, and no third-party repository. So a newer major than - # the distro's is a warning, and an older pinned one takes the compat set. + # the distro's cannot be installed, and an older pinned one takes the compat + # set. - name: Install LLVM from the Fedora repositories (Fedora) when: ansible_facts['distribution'] == 'Fedora' @@ -336,7 +412,7 @@ - rpm - -q - --queryformat=%{VERSION} - - "{{ ('llvm' ~ developer_rust_llvm_major) if developer_rust_llvm_compat else 'llvm' }}" + - "{{ ('llvm' ~ developer_rust_llvm_major) if developer_rust_llvm_compat | bool else 'llvm' }}" register: developer_rust_llvm_rpm changed_when: false check_mode: false @@ -348,9 +424,11 @@ # ======================================================================== # macOS: Homebrew # ======================================================================== - # Keg-only, so nothing is linked onto PATH and Apple's clang stays the - # default; a build reaches it through $(brew --prefix llvm)/bin. Homebrew - # builds no BOLT for macOS. + # llvm is keg-only, so Apple's clang stays the default and a build reaches + # brew's through $(brew --prefix llvm)/bin. lld is not keg-only: ld.lld, + # ld64.lld and wasm-ld land on PATH, which is harmless because Apple's + # linker is `ld` and nothing picks lld unless asked. Homebrew builds no BOLT + # for macOS. - name: Install LLVM from Homebrew (macOS) when: ansible_facts['distribution'] == 'MacOSX' @@ -364,14 +442,16 @@ changed_when: false check_mode: false + # lld split from the llvm formula at 19, so there is no lld@18; an older + # pin gets llvm@N alone, whose keg still carries its own ld.lld. - name: Choose the Homebrew LLVM formulae (macOS) ansible.builtin.set_fact: developer_rust_llvm_brew_formulae: >- - {{ ['llvm@' ~ developer_rust_llvm_major, 'lld@' ~ developer_rust_llvm_major] - if (developer_rust_llvm_request is match('^[0-9]+$') - and developer_rust_llvm_major | int < _brew_major | int) + {{ (['llvm@' ~ _n] + (['lld@' ~ _n] if _n | int >= 19 else [])) + if (developer_rust_llvm_request is match('^[0-9]+$') and _n | int < _brew_major | int) else ['llvm', 'lld'] }} vars: + _n: "{{ developer_rust_llvm_major }}" _brew_major: >- {{ (developer_rust_llvm_brew_info.stdout | from_json).formulae[0].versions.stable.split('.')[0] }} @@ -380,6 +460,7 @@ name: "{{ developer_rust_llvm_brew_formulae }}" state: present + # Two versions are listed while an old keg awaits `brew cleanup`. - name: Read the installed Homebrew LLVM version (macOS) ansible.builtin.command: argv: [brew, list, --versions, "{{ developer_rust_llvm_brew_formulae | first }}"] @@ -390,7 +471,8 @@ - name: Record the installed LLVM major (macOS) ansible.builtin.set_fact: developer_rust_llvm_system_major: >- - {{ developer_rust_llvm_brew_list.stdout | regex_search('\s(\d+)\.', '\1') | default(['0'], true) | first }} + {{ (developer_rust_llvm_brew_list.stdout | regex_findall('\s(\d+)\.') + | map('int') | list or [0]) | max }} # ======================================================================== # Drift against rustc @@ -401,23 +483,46 @@ # noqa: var-naming[no-role-prefix] -- deploy_warnings is the shared # accumulator the playbook reports at the end of the run. - - name: Warn when the system LLVM is older than wanted + - name: Warn when the system LLVM is older than rustc's ansible.builtin.set_fact: deploy_warnings: >- {{ (deploy_warnings | default([]) - + ['LLVM toolchain: the system LLVM is ' ~ _system ~ ', older than ' - ~ ((_wanted ~ ' (rust_llvm_version=' ~ developer_rust_llvm_request ~ ')') - if _system | int < _wanted | int - else (_rustc ~ ', the LLVM this rustc was built with')) - ~ ". Cross-language LTO against a rustc built on a newer LLVM fails;" - ~ " PGO and coverage still work through rustup's llvm-tools."]) | unique }} + + ['LLVM toolchain: the system LLVM is ' ~ _system ~ ', older than ' ~ _rustc + ~ ', the LLVM this rustc was built with. Cross-language LTO with this rustc' + ~ " fails until they match; PGO and coverage still work through rustup's" + ~ ' llvm-tools.']) | unique }} vars: _system: "{{ developer_rust_llvm_system_major | default('0') }}" - _wanted: "{{ developer_rust_llvm_major }}" _rustc: "{{ developer_rust_llvm_rustc_major | default('', true) }}" - when: >- - _system | int < _wanted | int - or (_rustc | length > 0 and _system | int < _rustc | int) + when: + - _rustc | length > 0 + - _system | int < _rustc | int + + # noqa: var-naming[no-role-prefix] -- deploy_warnings is the shared + # accumulator the playbook reports at the end of the run. + - name: Warn when a pinned LLVM major is not available here + ansible.builtin.set_fact: + deploy_warnings: >- + {{ (deploy_warnings | default([]) + + ['LLVM toolchain: rust_llvm_version=' ~ developer_rust_llvm_request + ~ ' is not packaged for this host, which has LLVM ' ~ _system ~ '.']) | unique }} + vars: + _system: "{{ developer_rust_llvm_system_major | default('0') }}" + when: + - developer_rust_llvm_request is match('^[0-9]+$') + - _system | int < developer_rust_llvm_major | int + - not (developer_rust_llvm_rustc_major | default('', true) | length > 0 + and _system | int < developer_rust_llvm_rustc_major | int) + + - name: Note that the system LLVM trails the newest release + ansible.builtin.debug: + msg: >- + The system LLVM ({{ developer_rust_llvm_system_major }}) trails LLVM + {{ developer_rust_llvm_major }}. Nothing on this host needs the newer one + yet: no rustc here is built on an LLVM newer than the system's. + when: + - developer_rust_llvm_request is not match('^[0-9]+$') + - developer_rust_llvm_system_major | default('0') | int < developer_rust_llvm_major | int - name: Note which llvm-profdata merges Rust profiles ansible.builtin.debug: @@ -431,6 +536,60 @@ - developer_rust_llvm_system_major | default('0') | int > developer_rust_llvm_rustc_major | int rescue: + # A source this run rewrote goes back to what it was, so a failed switch + # to a new major leaves the old one installable and apt readable. + - name: Restore the previous apt.llvm.org source (Ubuntu) + ansible.builtin.copy: + content: "{{ developer_rust_llvm_prev.content }}" + dest: "{{ developer_rust_llvm_sources }}" + owner: root + group: root + mode: '0644' + when: + - ansible_facts['distribution'] == 'Ubuntu' + - developer_rust_llvm_repo_written | default(false) | bool + - developer_rust_llvm_defer_to | default([]) | length == 0 + - developer_rust_llvm_prev.exists | default(false) | bool + + - name: Remove the apt.llvm.org source this run added (Ubuntu) + ansible.builtin.file: + path: "{{ developer_rust_llvm_sources }}" + state: absent + when: + - ansible_facts['distribution'] == 'Ubuntu' + - developer_rust_llvm_repo_written | default(false) | bool + - developer_rust_llvm_defer_to | default([]) | length == 0 + - not (developer_rust_llvm_prev.exists | default(false) | bool) + + # Only when our restored source no longer names this suite: otherwise the + # line put back is the conflict this run removed. + - name: Restore the one-line lists this run edited (Ubuntu) + ansible.builtin.copy: + content: "{{ item.content | b64decode }}" + dest: "{{ item.source }}" + owner: root + group: root + mode: '0644' + loop: "{{ developer_rust_llvm_foreign_before | default([]) }}" + loop_control: + label: "{{ item.source }}" + when: + - ansible_facts['distribution'] == 'Ubuntu' + - developer_rust_llvm_foreign_before | default([]) | length > 0 + - developer_rust_llvm_suite not in developer_rust_llvm_prev.content | default('') + + - name: Check apt reads its sources after the restore (Ubuntu) + ansible.builtin.command: + argv: [apt-get, update] + register: developer_rust_llvm_apt_after + changed_when: false + failed_when: false + when: + - ansible_facts['distribution'] == 'Ubuntu' + - not ansible_check_mode + - (developer_rust_llvm_repo_written | default(false) | bool) + or (developer_rust_llvm_foreign_before | default([]) | length > 0) + # noqa: var-naming[no-role-prefix] -- deploy_warnings is the shared # accumulator the playbook reports at the end of the run. - name: Record that the LLVM toolchain did not install @@ -438,4 +597,18 @@ deploy_warnings: >- {{ (deploy_warnings | default([]) + ['LLVM toolchain: ' ~ (ansible_failed_task.name | default('unknown task')) - ~ ' -- ' ~ (ansible_failed_result.msg | default('no message'))]) | unique }} + ~ ' -- ' ~ (ansible_failed_result.msg | default('no message')) + ~ ((' -- apt-get update still fails after restoring the sources: ' + ~ (developer_rust_llvm_apt_after.stderr_lines | default([]) + | select('match', '^E:') | join(' '))) + if developer_rust_llvm_apt_after.rc | default(0) != 0 else '')]) | unique }} + when: not ansible_check_mode + + # Check mode installs nothing, so a fresh host fails here for want of the + # packages a real run would have fetched; that is not worth a warning. + - name: Report the LLVM toolchain failure in check mode + ansible.builtin.debug: + msg: >- + LLVM toolchain (check mode): {{ ansible_failed_task.name | default('unknown task') }} + -- {{ ansible_failed_result.msg | default('no message') }} + when: ansible_check_mode diff --git a/ansible/roles/developer-rust/tasks/rust.yml b/ansible/roles/developer-rust/tasks/rust.yml index 1159ef3..847878a 100644 --- a/ansible/roles/developer-rust/tasks/rust.yml +++ b/ansible/roles/developer-rust/tasks/rust.yml @@ -918,11 +918,16 @@ # good config back, and pass, while cargo went on reading a stale file # somewhere else. A leftover config in the location that is NOT in effect is # exactly the trap this whole change exists to close. -# The linker gets the same check: it is the /usr/local/bin/clang link on Ubuntu, -# so a link left pointing at a removed LLVM major breaks every build the same way. +# The host linker gets the same check: it is the /usr/local/bin/clang link on +# Ubuntu, so a link left pointing at a removed LLVM major breaks every build the +# same way. - name: Verify no cargo config names an unusable rustc-wrapper or linker when: rust_verify_wrapper | default(true) | bool tags: ['rust-cache', 'rust-llvm'] + vars: + developer_rust_host_triple: >- + {{ hyperi_arch_gnu ~ ('-apple-darwin' if ansible_facts['distribution'] == 'MacOSX' + else '-unknown-linux-gnu') }} block: - name: Read every candidate cargo config ansible.builtin.slurp: @@ -1005,20 +1010,38 @@ | selectattr('rc', 'defined') | selectattr('rc', 'ne', 0) | list | length > 0 - # Plain and dotted keys, both quote styles, one line each -- the same - # reasoning as the rustc-wrapper extraction above. - - name: Extract the linker each config names + # The host target only: that is the table hyperi-rust-setup writes, and a + # cross target's linker is installed when that cross build is set up, not + # by this role. Table and dotted forms, quoted triple or bare. + - name: Extract the host target's linker from each config ansible.builtin.set_fact: developer_rust_linkers: >- - {{ developer_rust_cargo_configs.results - | selectattr('content', 'defined') | map(attribute='content') - | map('b64decode') | join('\n') - | regex_findall('(?m)^[ \t]*(?:target\.[^=\n]+\.)?linker[ \t]*=[ \t]*["\x27]([^"\x27\n]+)["\x27]') + {{ (_text | regex_findall('(?ms)^[ \t]*\[target\.(?:"' ~ _t ~ '"|' ~ _t ~ ')\][ \t]*$(.*?)(?=^[ \t]*\[|\Z)') + | map('regex_findall', '(?m)^[ \t]*linker[ \t]*=[ \t]*["\x27]([^"\x27\n]+)["\x27]') | flatten) + + (_text | regex_findall('(?m)^[ \t]*target\.(?:"' ~ _t ~ '"|' ~ _t ~ ')\.linker[ \t]*=[ \t]*["\x27]([^"\x27\n]+)["\x27]')) | unique }} - - - name: Check that each configured linker resolves + vars: + _text: >- + {{ developer_rust_cargo_configs.results | selectattr('content', 'defined') + | map(attribute='content') | map('b64decode') | join('\n') }} + _t: "{{ developer_rust_host_triple | regex_escape }}" + + # PATH first, then the toolchain's own bin directory, which is where + # `rust-lld` lives and is never on PATH. + - name: Check that the host target's linker resolves ansible.builtin.command: - argv: [bash, -lc, '[ -x "$(command -v -- "$1")" ]', bash, "{{ item }}"] + argv: + - bash + - -lc + - | + [ -x "$(command -v -- "$1")" ] && exit 0 + for r in rustc "${CARGO_HOME:-$HOME/.cargo}/bin/rustc"; do + s=$("$r" --print sysroot 2>/dev/null) && [ -x "$s/lib/rustlib/$2/bin/$1" ] && exit 0 + done + exit 1 + - bash + - "{{ item }}" + - "{{ developer_rust_host_triple }}" become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" become_user: "{{ actual_user if ansible_facts['distribution'] != 'MacOSX' else omit }}" register: developer_rust_linker_checks @@ -1027,10 +1050,10 @@ check_mode: false loop: "{{ developer_rust_linkers }}" - - name: Fail when a cargo config names a linker that does not exist + - name: Fail when a cargo config names a host linker that does not exist ansible.builtin.fail: msg: >- - A cargo config on this host sets linker = + A cargo config on this host sets the host target's linker = "{{ developer_rust_linker_checks.results | selectattr('rc', 'defined') | selectattr('rc', 'ne', 0) | map(attribute='item') | join('", "') }}",