diff --git a/ansible/roles/developer-rust/README.md b/ansible/roles/developer-rust/README.md index 90e0c87..d309b7e 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 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 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`, 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. A pinned major the host cannot package also warns. + ## 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..a9c0ef1 --- /dev/null +++ b/ansible/roles/developer-rust/tasks/llvm.yml @@ -0,0 +1,614 @@ +--- +# 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, +# 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. 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 + 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 + + # `| 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) | int | 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 | int >= 18 + fail_msg: >- + 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' + 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 + + - 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: "{{ developer_rust_llvm_key_url }}" + 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 + + # 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: + - _pubs | length == 1 + - _fpr == _pinned + fail_msg: >- + apt.llvm.org key file holds {{ _pubs | length }} primary key(s), first + fingerprint {{ _fpr or 'missing' }}; expected exactly one, {{ _pinned }} + quiet: true + vars: + _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. + - 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' + + # 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 + 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: "{{ '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. + - 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: 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]+$')) + + # 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 }}" + + # ======================================================================== + # 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 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' + 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 | bool 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 + # ======================================================================== + # 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' + 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 + + # 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@' ~ _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] }} + + - name: Install the LLVM toolchain formulae (macOS) + community.general.homebrew: + 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 }}"] + 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_findall('\s(\d+)\.') + | map('int') | list or [0]) | max }} + + # ======================================================================== + # 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 rustc's + ansible.builtin.set_fact: + deploy_warnings: >- + {{ (deploy_warnings | default([]) + + ['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') }}" + _rustc: "{{ developer_rust_llvm_rustc_major | default('', true) }}" + 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: + 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: + # 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 + 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')) + ~ ((' -- 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/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..847878a 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,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. -- name: Verify no cargo config names an unusable rustc-wrapper +# 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'] + 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: @@ -980,6 +1010,61 @@ | selectattr('rc', 'defined') | selectattr('rc', 'ne', 0) | list | length > 0 + # 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: >- + {{ (_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 }} + 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")" ] && 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 + changed_when: false + failed_when: false + check_mode: false + loop: "{{ developer_rust_linkers }}" + + - 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 the host target's 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