diff --git a/ansible/playbooks/group_vars/all.yml b/ansible/playbooks/group_vars/all.yml index 6ade18a..cad7c4a 100644 --- a/ansible/playbooks/group_vars/all.yml +++ b/ansible/playbooks/group_vars/all.yml @@ -66,3 +66,21 @@ hyperi_core_versions: # 26 and 24. node_major: 24 node_major_previous: 22 + +# ============================================================================ +# apt sources the roles own outright +# ============================================================================ +# Any other source naming one of these repositories is removed before ours is +# written (system_cleanup/tasks/apt_source_exclusive.yml): apt stops reading +# every source once two entries for one repository carry different keys. +# match is a regex over the repository's host and path; file is our .sources. +hyperi_exclusive_apt_sources: + git: + match: 'launchpad(content)?\.net/git-core/ppa' + file: /etc/apt/sources.list.d/git-core-ppa.sources + ghostty: + match: 'launchpad(content)?\.net/mkasberg/ghostty-ubuntu' + file: /etc/apt/sources.list.d/ghostty-ppa.sources + claude: + match: 'downloads\.claude\.ai/claude-code/apt' + file: /etc/apt/sources.list.d/claude-code.sources diff --git a/ansible/roles/developer-ai/defaults/main.yml b/ansible/roles/developer-ai/defaults/main.yml index e6c1d52..2562cb8 100644 --- a/ansible/roles/developer-ai/defaults/main.yml +++ b/ansible/roles/developer-ai/defaults/main.yml @@ -5,9 +5,9 @@ # One PATH for every probe and every install in this role, covering both # platforms so the tasks do not each carry their own copy. # -# `{{ user_home }}/.local/bin` is the load-bearing entry and the reason this is -# not just the inherited PATH: claude, codex and uv-installed tools all land -# there, PER USER. `ansible_facts['env'].PATH` is the CONNECTING user's, which +# `{{ user_home }}/.local/bin` is the reason this is not just the inherited +# PATH: codex and uv-installed tools land there, PER +# USER. `ansible_facts['env'].PATH` is the CONNECTING user's, which # on a fleet machine is a service account that has none of them -- it is # appended for the system binaries, never relied on for the agent CLIs. developer_ai_env: diff --git a/ansible/roles/developer-ai/meta/main.yml b/ansible/roles/developer-ai/meta/main.yml index d46dc23..a01c2aa 100644 --- a/ansible/roles/developer-ai/meta/main.yml +++ b/ansible/roles/developer-ai/meta/main.yml @@ -30,6 +30,6 @@ galaxy_info: # # The dependencies are therefore PROBED on the target and reported by name -- # see tasks/init.yml. That is also the honest shape for a host of unknown -# state: the answer to "is Claude Code here" is per-USER on Linux (it installs -# to ~/.local/bin/claude), so no role graph could answer it anyway. +# state: "is Claude Code here" depends on what that user's PATH reaches, so no +# role graph could answer it anyway. dependencies: [] diff --git a/ansible/roles/developer-ai/tasks/init.yml b/ansible/roles/developer-ai/tasks/init.yml index 9dfc43b..0d04d50 100644 --- a/ansible/roles/developer-ai/tasks/init.yml +++ b/ansible/roles/developer-ai/tasks/init.yml @@ -1,13 +1,7 @@ --- # Dependency probe. Everything else in this role gates on the facts set here. # -# WHY PROBE RATHER THAN DECLARE. Two of the three dependencies cannot be -# expressed in the role graph at all (see meta/main.yml), and the one that -# could -- Claude Code -- is installed PER USER on Linux, to -# ~/.local/bin/claude. A role graph answers "was this role selected", not "does -# this user have the binary", and on a host of unknown state those are -# different questions. A converge that assumed the first would report success -# while installing a plugin that throws on every invocation. +# Probed rather than declared: a role graph answers "was this role selected", not "does this user have the binary" (see meta/main.yml), and assuming the first installs a plugin that throws on every invocation. # # Every probe below therefore runs AS THE TARGET USER, not as whoever Ansible # connected as. On a fleet machine those differ: the connection is a service diff --git a/ansible/roles/developer-ai/tasks/main.yml b/ansible/roles/developer-ai/tasks/main.yml index 6cbf8b4..e22aab2 100644 --- a/ansible/roles/developer-ai/tasks/main.yml +++ b/ansible/roles/developer-ai/tasks/main.yml @@ -20,8 +20,8 @@ # a machine that asked for AI tooling. The plugin's gate names the `claude` tag # instead, which installs Claude Code and nothing else. # -# EVERY TOOL HERE IS PER USER. Claude Code, Codex and the plugin all live under -# the TARGET user's home, so `install.sh`'s once-per-user loop is what makes a +# EVERY TOOL HERE IS PER USER. Codex and the plugin both live under the TARGET +# user's home, so `install.sh`'s once-per-user loop is what makes a # multi-user box come out right, and every task below runs under become_user # rather than as the connecting account. # diff --git a/ansible/roles/developer-gui/defaults/main.yml b/ansible/roles/developer-gui/defaults/main.yml index ee98c7f..d7b5ffe 100644 --- a/ansible/roles/developer-gui/defaults/main.yml +++ b/ansible/roles/developer-gui/defaults/main.yml @@ -1,6 +1,20 @@ --- # Developer-GUI role defaults. +# ============================================================================ +# Ghostty on Ubuntu -- ppa:mkasberg/ghostty-ubuntu +# ============================================================================ +# Launchpad builds the PPA per Ubuntu series, and a new series appears only +# once Launchpad opens it. Oldest first; the last entry is the fallback for a +# series the PPA does not publish yet. +ghostty_ppa_supported_suites: + - noble + - resolute +# Launchpad's signing key for the PPA, as its archive API reports it +# (signing_key_fingerprint on ~mkasberg/+archive/ubuntu/ghostty-ubuntu). +ghostty_ppa_signing_fingerprint: 0721FDF5FECB88DC6920361657C8EF455CEAE491 # gitleaks:allow -- public key fingerprint +ghostty_ppa_signing_key_url: "https://keyserver.ubuntu.com/pks/lookup?op=get&options=mr&search=0x{{ ghostty_ppa_signing_fingerprint }}" + # ============================================================================ # VSCode privacy + AI-upsell de-nag profile -- OPT-IN (hyperi-io/hyperi-developer#7) # ============================================================================ diff --git a/ansible/roles/developer-gui/tasks/ghostty.yml b/ansible/roles/developer-gui/tasks/ghostty.yml index 21809b4..23ee480 100644 --- a/ansible/roles/developer-gui/tasks/ghostty.yml +++ b/ansible/roles/developer-gui/tasks/ghostty.yml @@ -36,10 +36,17 @@ # ============================================================ # Ghostty Installation - Fedora (COPR) # ============================================================ +# dnf5's own copr command, because community.general.copr needs dnf4's python3-dnf, which Fedora Server, cloud and minimal images lack. +- name: Ensure the dnf5 copr plugin is present (Fedora) + ansible.builtin.dnf: + name: dnf5-plugins + state: present + when: ansible_facts['distribution'] == 'Fedora' + - name: Enable scottames/ghostty COPR (Fedora) - community.general.copr: - name: scottames/ghostty - state: enabled + ansible.builtin.command: + argv: [dnf, -y, copr, enable, scottames/ghostty] + creates: /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:scottames:ghostty.repo when: ansible_facts['distribution'] == 'Fedora' - name: Install Ghostty (Fedora) @@ -49,53 +56,198 @@ when: ansible_facts['distribution'] == 'Fedora' # ============================================================ -# Ghostty Installation - Ubuntu (pre-built .deb) +# Ghostty Installation - Ubuntu (ppa:mkasberg/ghostty-ubuntu) # ============================================================ -- name: Get latest Ghostty release - ansible.builtin.uri: - url: https://api.github.com/repos/mkasberg/ghostty-ubuntu/releases/latest - return_content: true - headers: "{{ hyperi_github_headers }}" - register: ghostty_latest +- name: Install Ghostty from its PPA (Ubuntu) + vars: + developer_gui_ghostty_keyring: /etc/apt/keyrings/ghostty-ppa.asc + developer_gui_ghostty_repo_uri: http://ppa.launchpadcontent.net/mkasberg/ghostty-ubuntu/ubuntu + developer_gui_ghostty_pinned: "{{ ghostty_ppa_signing_fingerprint | upper | replace(' ', '') }}" + # Check mode adds no repo, so there is nothing to install from yet. + developer_gui_ghostty_repo_pending: "{{ ansible_check_mode and developer_gui_ghostty_repo is changed }}" when: ansible_facts['distribution'] == 'Ubuntu' - check_mode: false + block: + # Runs before any apt call, because another source for this repository + # with a different key stops apt reading anything. + - name: Remove other apt sources for the Ghostty PPA + ansible.builtin.include_role: + name: system_cleanup + tasks_from: apt_source_exclusive.yml + # The include is already gated by this file's tags, and its tasks carry none. + apply: + tags: ['always'] + vars: + system_cleanup_apt_source_name: ghostty -# Assets are named ghostty___.deb, e.g. -# ghostty_1.3.1-0.ppa2_arm64_24.04.deb. arm64 debs exist for every suite amd64 -# has, so this is a straight token swap. -- name: Find .deb file for detected Ubuntu version - ansible.builtin.set_fact: - ghostty_deb_url: "{{ item.browser_download_url }}" - loop: "{{ ghostty_latest.json.assets }}" - when: - - ansible_facts['distribution'] == 'Ubuntu' - - "'_' + hyperi_arch_deb + '_' + ansible_facts['distribution_version'] + '.deb' in item.name" + # A minimal Ubuntu ships neither: gpg reads the key fingerprint, and + # deb822_repository needs python3-debian. + - name: Ensure gpg and python3-debian are present + ansible.builtin.apt: + name: [gpg, python3-debian] + state: present -- name: Download Ghostty .deb - ansible.builtin.get_url: - url: "{{ ghostty_deb_url }}" - dest: /tmp/ghostty.deb - mode: '0644' - when: - - ansible_facts['distribution'] == 'Ubuntu' - - ghostty_deb_url is defined + - name: Create the apt keyring directory + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: '0755' -- name: Install Ghostty (Ubuntu) - ansible.builtin.apt: - deb: /tmp/ghostty.deb - state: present - when: - - ansible_facts['distribution'] == 'Ubuntu' - - ghostty_deb_url is defined - - not ansible_check_mode + # failed_when: on a fresh host there is no trusted key to read yet. + - name: Read the trusted Ghostty PPA signing key + ansible.builtin.command: + argv: [gpg, --show-keys, --with-colons, "{{ developer_gui_ghostty_keyring }}"] + register: developer_gui_ghostty_trusted + changed_when: false + failed_when: false + check_mode: false -- name: Remove Ghostty .deb - ansible.builtin.file: - path: /tmp/ghostty.deb - state: absent - when: - - ansible_facts['distribution'] == 'Ubuntu' - - ghostty_deb_url is defined + # A trusted file that already holds exactly the pinned key needs no fetch, + # so a keyserver outage cannot fail the converge of a provisioned host. + - name: Check whether the trusted Ghostty PPA key is already the pinned one + ansible.builtin.set_fact: + developer_gui_ghostty_key_current: >- + {{ developer_gui_ghostty_trusted.rc | default(1) == 0 + and (developer_gui_ghostty_trusted.stdout_lines | select('match', '^pub:') | list | length) == 1 + and ((developer_gui_ghostty_trusted.stdout_lines | select('match', '^fpr:') | first | default('')).split(':')[9] + | default('')) == developer_gui_ghostty_pinned }} + + # Not in check mode: keyserver.ubuntu.com answers the HEAD request get_url + # makes there with 405, and with no download there is nothing to verify. + - name: Fetch and verify the Ghostty PPA signing key + when: + - not developer_gui_ghostty_key_current | bool + - not ansible_check_mode + block: + - name: Download the Ghostty PPA signing key + ansible.builtin.get_url: + url: "{{ ghostty_ppa_signing_key_url }}" + dest: "{{ developer_gui_ghostty_keyring }}.unverified" + mode: '0644' + force: true + + - name: Read the Ghostty PPA signing key fingerprint + ansible.builtin.command: + argv: [gpg, --show-keys, --with-colons, "{{ developer_gui_ghostty_keyring }}.unverified"] + register: developer_gui_ghostty_key + changed_when: false + + # Exactly one primary key, and the pinned one: apt trusts every key in a + # Signed-By file, so a second key riding along would be trusted too. + - name: Verify the Ghostty PPA signing key fingerprint + ansible.builtin.assert: + that: + - developer_gui_ghostty_key_count | int == 1 + - developer_gui_ghostty_key_fpr == developer_gui_ghostty_pinned + fail_msg: >- + Ghostty PPA key file holds {{ developer_gui_ghostty_key_count }} key(s), primary + {{ developer_gui_ghostty_key_fpr or 'missing' }}; expected exactly one, + {{ ghostty_ppa_signing_fingerprint }} + quiet: true + vars: + developer_gui_ghostty_key_count: "{{ developer_gui_ghostty_key.stdout_lines | select('match', '^pub:') | list | length }}" + developer_gui_ghostty_key_fpr: >- + {{ (developer_gui_ghostty_key.stdout_lines | select('match', '^fpr:') + | first | default('')).split(':')[9] | default('') }} + + - name: Install the verified Ghostty PPA signing key + ansible.builtin.copy: + src: "{{ developer_gui_ghostty_keyring }}.unverified" + dest: "{{ developer_gui_ghostty_keyring }}" + remote_src: true + owner: root + group: root + mode: '0644' + + # The running release when the PPA publishes it, else the newest series it + # does publish -- see ghostty_ppa_supported_suites. + # + # http for the same reason as the git-core PPA in developer/tasks/git.yml: + # Launchpad over https hangs on half-working IPv6, and signed_by verifies + # what arrives whatever carried it. + - name: Add the Ghostty PPA repository + ansible.builtin.deb822_repository: + name: ghostty-ppa + types: deb + uris: "{{ developer_gui_ghostty_repo_uri }}" + suites: >- + {{ ansible_facts['distribution_release'] + if ansible_facts['distribution_release'] in ghostty_ppa_supported_suites + else ghostty_ppa_supported_suites | last }} + components: main + signed_by: "{{ developer_gui_ghostty_keyring }}" + state: present + register: developer_gui_ghostty_repo + + - name: Refresh the apt cache for the Ghostty PPA + ansible.builtin.apt: + update_cache: true + changed_when: false + when: not developer_gui_ghostty_repo_pending + + - name: Read the ghostty versions apt offers + ansible.builtin.command: + argv: [apt-cache, madison, ghostty] + register: developer_gui_ghostty_madison + changed_when: false + check_mode: false + when: not developer_gui_ghostty_repo_pending + + # Empty when ghostty is not installed. + - name: Read the installed ghostty version + ansible.builtin.command: + argv: [dpkg-query, -W, '-f=${Version}', ghostty] + register: developer_gui_ghostty_installed + changed_when: false + failed_when: false + check_mode: false + + # A GitHub-release .deb (1.3.1-0~ppa2) sorts above the PPA's 1.3.1~ppa2-noble1, so apt never replaces it and this step swaps it for the PPA build. + - name: Replace a ghostty GitHub-release .deb with the PPA build + ansible.builtin.apt: + name: "ghostty={{ developer_gui_ghostty_ppa_versions | community.general.version_sort | last }}" + allow_downgrade: true + vars: + developer_gui_ghostty_ppa_versions: >- + {{ developer_gui_ghostty_madison.stdout_lines | select('search', developer_gui_ghostty_repo_uri) + | map('split', '|') | map(attribute=1) | map('trim') | list }} + when: + - not developer_gui_ghostty_repo_pending + - developer_gui_ghostty_installed.rc == 0 + - developer_gui_ghostty_installed.stdout is match('^[0-9.]+-0~ppa[0-9]+$') + - developer_gui_ghostty_ppa_versions | length > 0 + + - name: Install Ghostty (Ubuntu) + ansible.builtin.apt: + name: ghostty + state: present + when: not developer_gui_ghostty_repo_pending + + rescue: + # Deleted as a file on a key mismatch, leaving the trusted key: deb822_repository state=absent would delete that keyring too. + - name: Remove the Ghostty PPA repository after a key mismatch + ansible.builtin.file: + path: "{{ hyperi_exclusive_apt_sources.ghostty.file }}" + state: absent + when: ansible_failed_task.action in ['assert', 'ansible.builtin.assert'] + + - name: Remove the Ghostty PPA source if apt reported a conflicting key + ansible.builtin.include_role: + name: system_cleanup + tasks_from: apt_source_exclusive.yml + apply: + tags: ['always'] + vars: + system_cleanup_apt_source_name: ghostty + system_cleanup_apt_source_failed: "{{ ansible_failed_result }}" + + # unique: a persona can pull this role into the play a second time. + - name: Record that Ghostty did not install (Ubuntu) + # noqa: var-naming[no-role-prefix] -- deploy_warnings is the shared accumulator + ansible.builtin.set_fact: + deploy_warnings: >- + {{ (deploy_warnings | default([]) + + ['Ghostty: ' ~ (ansible_failed_task.name | default('unknown task')) + ~ ' -- ' ~ (ansible_failed_result.msg | default('no message'))]) | unique }} # ============================================================ # Ghostty Installation - macOS (Homebrew) @@ -228,7 +380,7 @@ # ============================================================ # CLI on PATH # ============================================================ -# Linux gets this free: the .deb and COPR both drop /usr/bin/ghostty. +# Linux gets this free: the PPA and COPR packages both drop /usr/bin/ghostty. # # macOS does not. The cask installs Ghostty.app plus manpages and completions, # but no binary -- so `ghostty +validate-config`, `ghostty +list-themes` and diff --git a/ansible/roles/developer/files/update/hyperi-update-linux.sh b/ansible/roles/developer/files/update/hyperi-update-linux.sh index 8d4b40f..f350dc5 100644 --- a/ansible/roles/developer/files/update/hyperi-update-linux.sh +++ b/ansible/roles/developer/files/update/hyperi-update-linux.sh @@ -16,7 +16,6 @@ # * Go toolchain (/usr/local/go, no upstream repo) -- needs sudo # * fnm Node majors (the n-1 Node, per user) -- user # * Release binaries (/usr/local/bin, no repo/snap) -- needs sudo -# * Claude Code CLI (self-installed under ~/.local) -- user # * Codex CLI (re-run of the installer) -- user # * Codex plugin (claude plugin update) -- user # @@ -60,7 +59,7 @@ CARGO_BIN="${CARGO_HOME:-$HOME/.cargo}/bin" # Make user-level tools reachable even when launched from a GUI/.desktop entry # or the systemd unit, neither of which sources the login shell (rustup and the -# cargo tools live in the cargo home, uv and claude in ~/.local/bin, Go in +# cargo tools live in the cargo home, uv in ~/.local/bin, Go in # /usr/local/go/bin, the go-installed tools in ~/go/bin and the pnpm globals in # PNPM_HOME). export PNPM_HOME="${PNPM_HOME:-$HOME/.local/share/pnpm}" @@ -165,7 +164,6 @@ if [[ "$ASSUME_YES" -eq 0 ]]; then have npm && printf ' - npm global tools + pnpm\n' have pnpm && printf ' - pnpm global tools\n' printf ' - release binaries in /usr/local/bin that no package covers, and uv in ~/.local/bin on Ubuntu\n' - have claude && printf ' - Claude Code CLI\n' have codex && printf ' - Codex CLI (re-run of the official installer)\n' have claude && printf ' - the Codex plugin for Claude Code, if installed\n' [[ -f "$ARCANE_DIR/compose.yaml" ]] && printf ' - Arcane (pull + recreate)\n' @@ -948,16 +946,6 @@ else skip "unknown CPU architecture ($(uname -m)) -- skipping release binaries" fi -# --- Claude Code ----------------------------------------------------------- -# The role installs the native build under ~/.local, whose own updater this is. -# Run as the normal user (NOT under sudo) so it updates ~/.local, not root's. -section "Claude Code" -if have claude; then - run "claude update" claude update -else - skip "claude not found in PATH" -fi - # --- Codex CLI ------------------------------------------------------------- # No apt/dnf repo, no snap, no language manager -- and not a release binary # either, because the release asset is a package TREE that has to be staged and @@ -989,8 +977,7 @@ else fi # --- Codex plugin for Claude Code ------------------------------------------ -# `claude update` moves the CLI only; a marketplace plugin has its own update -# verb. Gated on the plugin actually being installed as well as on claude, so +# The package manager moves the CLI only; a marketplace plugin has its own update verb. Gated on the plugin actually being installed as well as on claude, so # a box that never opted into the AI tooling does not take an update attempt # for a plugin it has never had. --json because the human-readable listing is # not a contract; the id is, and it is plugin@marketplace. diff --git a/ansible/roles/developer/tasks/git.yml b/ansible/roles/developer/tasks/git.yml index 36976bb..0206efe 100644 --- a/ansible/roles/developer/tasks/git.yml +++ b/ansible/roles/developer/tasks/git.yml @@ -25,13 +25,27 @@ - name: Install latest Git via PPA (Ubuntu) block: - # A minimal Ubuntu ships neither: gpg reads the key fingerprint below, and - # hyperi-update shells out to curl. - - name: Ensure gpg and curl are present + # Runs before any apt call, because another source for this PPA with a + # different key (`add-apt-repository ppa:git-core/ppa` writes one) stops apt + # reading anything. + - name: Remove other apt sources for the git-core PPA + ansible.builtin.include_role: + name: system_cleanup + tasks_from: apt_source_exclusive.yml + # The include is already gated by this file's tags, and its tasks carry none. + apply: + tags: ['always'] + vars: + system_cleanup_apt_source_name: git + + # A minimal Ubuntu ships none of these: gpg reads the key fingerprint below, + # hyperi-update shells out to curl, and deb822_repository needs python3-debian. + - name: Ensure gpg, curl and python3-debian are present ansible.builtin.apt: name: - curl - gpg + - python3-debian state: present update_cache: true diff --git a/ansible/roles/developer/tasks/init.yml b/ansible/roles/developer/tasks/init.yml index a3a5d60..d3ebcf1 100644 --- a/ansible/roles/developer/tasks/init.yml +++ b/ansible/roles/developer/tasks/init.yml @@ -193,6 +193,22 @@ ansible.builtin.debug: msg: "GNOME desktop: {{ 'Installed' if has_gnome else 'Not installed' }}" +# Runs before the first apt task in the play, so a host where another source now +# duplicates one of ours with a different key reads its sources again. +- name: Remove apt sources that conflict with the ones the roles wrote (Ubuntu) + ansible.builtin.include_role: + name: system_cleanup + tasks_from: apt_source_exclusive.yml + apply: + tags: ['always'] + vars: + system_cleanup_apt_source_name: "{{ developer_owned_apt_source }}" + system_cleanup_apt_source_if_present: true + loop: "{{ hyperi_exclusive_apt_sources | list }}" + loop_control: + loop_var: developer_owned_apt_source + when: ansible_facts['distribution'] == 'Ubuntu' + # Install psutil for dconf module (required for GNOME settings tasks) - name: Install psutil for dconf module (Fedora) ansible.builtin.dnf: diff --git a/ansible/roles/soe/defaults/main.yml b/ansible/roles/soe/defaults/main.yml index 173eea8..02ffcf0 100644 --- a/ansible/roles/soe/defaults/main.yml +++ b/ansible/roles/soe/defaults/main.yml @@ -1,4 +1,14 @@ --- +# Claude Code on Linux comes from Anthropic's signed apt/dnf repository. The +# fingerprint is the release signing key published at +# https://code.claude.com/docs/en/setup, checked before the key is trusted. +soe_claude_key_url: https://downloads.claude.ai/keys/claude-code.asc +soe_claude_key_fingerprint: 31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE # gitleaks:allow -- public key fingerprint +# `stable` can lag a just-launched model by about a week; set `latest` to follow every release. +soe_claude_channel: stable +soe_claude_apt_repo_url: "https://downloads.claude.ai/claude-code/apt/{{ soe_claude_channel }}" +soe_claude_rpm_repo_url: "https://downloads.claude.ai/claude-code/rpm/{{ soe_claude_channel }}" + # Arcane -- container management UI (https://getarcane.app), OPT-IN. # # Off by default: it is a web UI holding the Docker socket, so it goes on only diff --git a/ansible/roles/soe/tasks/claude.yml b/ansible/roles/soe/tasks/claude.yml index 5b6e531..606746c 100644 --- a/ansible/roles/soe/tasks/claude.yml +++ b/ansible/roles/soe/tasks/claude.yml @@ -1,10 +1,9 @@ --- # Claude Code CLI installation. THE BINARY ONLY. # -# Linux: Native binary download with SHA256 verification from manifest -# macOS: Homebrew cask -# -# Binary location: ~/.local/bin/claude (installed by 'claude install') +# Linux: Anthropic's signed apt/dnf repository, stable channel, swept by the +# host's own package updates. +# macOS: Homebrew cask. # # The HyperI managed settings moved to claude_policy.yml, and the split is the # point. Both used to live here under the same `claude` tag, so `--tags claude` @@ -15,14 +14,6 @@ # developer-ai's Codex plugin hard-requires Claude Code, and telling someone to # run `--tags claude` has to mean only that. -# ============================================================================ -# Distribution URL -# ============================================================================ - -- name: Set Claude Code distribution URL - ansible.builtin.set_fact: - claude_gcs_bucket: "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases" - # ============================================================================ # Cleanup deprecated npm installation # ============================================================================ @@ -50,102 +41,222 @@ when: ansible_facts['distribution'] == 'MacOSX' # ============================================================================ -# Linux - Native binary installation with SHA256 verification +# Linux - Anthropic's package repository # ============================================================================ -- name: Install Claude Code native binary (Linux) +- name: Install Claude Code from Anthropic's package repository (Linux) + vars: + soe_claude_key_dest: >- + {{ '/etc/apt/keyrings/claude-code.asc' if ansible_facts['distribution'] == 'Ubuntu' + else '/etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code' }} + # Check mode adds no repo, so there is nothing to install from yet. + soe_claude_repo_pending: >- + {{ ansible_check_mode and ((soe_claude_deb_repo | default({})) is changed + or (soe_claude_rpm_repo | default({})) is changed) }} + become: true + when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] block: - - name: Fetch latest Claude Code version - ansible.builtin.uri: - url: "{{ claude_gcs_bucket }}/latest" - return_content: true - register: claude_latest_version - check_mode: false + # Runs before any apt call, because another source for this repository + # with a different key stops apt reading anything. + - name: Remove other apt sources for the Claude Code repository (Ubuntu) + ansible.builtin.include_role: + name: system_cleanup + tasks_from: apt_source_exclusive.yml + # The include is already gated by this file's tags, and its tasks carry none. + apply: + tags: ['always'] + vars: + system_cleanup_apt_source_name: claude + when: ansible_facts['distribution'] == 'Ubuntu' - - name: Set Claude Code version fact - ansible.builtin.set_fact: - claude_version: "{{ claude_latest_version.content | trim }}" + # A minimal Ubuntu ships gpgv but not gpg, which the key check needs, and + # deb822_repository needs python3-debian. + - name: Ensure the Claude Code repository prerequisites are present + ansible.builtin.package: + name: "{{ ['gpg', 'python3-debian'] if ansible_facts['distribution'] == 'Ubuntu' else ['gnupg2'] }}" + state: present - - name: Determine Claude Code platform - ansible.builtin.set_fact: - claude_platform: >- - {%- if ansible_facts['architecture'] == 'x86_64' -%} - linux-x64 - {%- elif ansible_facts['architecture'] == 'aarch64' -%} - linux-arm64 - {%- else -%} - unsupported - {%- endif -%} - - - name: Fail if unsupported architecture - ansible.builtin.fail: - msg: "Unsupported architecture for Claude Code: {{ ansible_facts['architecture'] }}" - when: claude_platform == 'unsupported' - - - name: Fetch Claude Code manifest for checksum - ansible.builtin.uri: - url: "{{ claude_gcs_bucket }}/{{ claude_version }}/manifest.json" - return_content: true - register: claude_manifest - check_mode: false - - - name: Set Claude Code checksum fact - ansible.builtin.set_fact: - claude_checksum: "{{ claude_manifest.json.platforms[claude_platform].checksum }}" - - - name: Create temporary download directory + - name: Create the apt keyring directory (Ubuntu) ansible.builtin.file: - path: /tmp/claude-install + path: /etc/apt/keyrings state: directory mode: '0755' + when: ansible_facts['distribution'] == 'Ubuntu' - - name: Download Claude Code binary with checksum verification + # Downloaded under a name no repo trusts, and forced so a rejected download + # is replaced on the next run instead of kept by an If-Modified-Since 304. + - name: Download the Claude Code signing key ansible.builtin.get_url: - url: "{{ claude_gcs_bucket }}/{{ claude_version }}/{{ claude_platform }}/claude" - dest: /tmp/claude-install/claude - mode: '0755' - checksum: "sha256:{{ claude_checksum }}" - when: not ansible_check_mode + url: "{{ soe_claude_key_url }}" + dest: "{{ soe_claude_key_dest }}.unverified" + mode: '0644' + force: true + register: soe_claude_key_download - - name: Create ~/.local/bin directory - ansible.builtin.file: - path: "{{ user_home }}/.local/bin" - state: directory - mode: '0755' - owner: "{{ actual_user }}" - group: "{{ actual_user }}" + # Check mode downloads nothing, so there is no new key to read. + - name: Read the Claude Code signing key fingerprint + ansible.builtin.command: + argv: [gpg, --show-keys, --with-colons, "{{ soe_claude_key_dest }}.unverified"] + register: soe_claude_key_read + changed_when: false + check_mode: false + when: not (ansible_check_mode and soe_claude_key_download is changed) + + # Exactly one primary key, and the pinned one: apt and rpm trust every key + # in the file, so a second key riding along would be trusted too. + - name: Verify the Claude Code signing key fingerprint + ansible.builtin.assert: + that: + - soe_claude_key_primaries | int == 1 + - soe_claude_key_fpr == soe_claude_key_fingerprint | upper | replace(' ', '') + fail_msg: >- + {{ soe_claude_key_url }} holds {{ soe_claude_key_primaries }} key(s), first + {{ soe_claude_key_fpr or 'missing' }}; expected only {{ soe_claude_key_fingerprint }} + quiet: true + when: not (ansible_check_mode and soe_claude_key_download is changed) + vars: + soe_claude_key_primaries: "{{ soe_claude_key_read.stdout_lines | select('match', '^pub:') | list | length }}" + soe_claude_key_fpr: >- + {{ (soe_claude_key_read.stdout_lines | select('match', '^fpr:') | first | default('')).split(':')[9] | default('') }} + + - name: Install the verified Claude Code signing key + ansible.builtin.copy: + src: "{{ soe_claude_key_dest }}.unverified" + dest: "{{ soe_claude_key_dest }}" + remote_src: true + owner: root + group: root + mode: '0644' + when: not (ansible_check_mode and soe_claude_key_download is changed) + + - name: Add the Claude Code apt repository (Ubuntu) + ansible.builtin.deb822_repository: + name: claude-code + types: deb + uris: "{{ soe_claude_apt_repo_url }}" + suites: "{{ soe_claude_channel }}" + components: main + signed_by: /etc/apt/keyrings/claude-code.asc + state: present + register: soe_claude_deb_repo + when: ansible_facts['distribution'] == 'Ubuntu' + + # Both the packages and the repo metadata are signed with the same key. + - name: Add the Claude Code repository (Fedora) + ansible.builtin.yum_repository: + name: claude-code + description: Claude Code + baseurl: "{{ soe_claude_rpm_repo_url }}" + enabled: true + gpgcheck: true + repo_gpgcheck: true + gpgkey: file:///etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code + register: soe_claude_rpm_repo + when: ansible_facts['distribution'] == 'Fedora' - - name: Run claude install to set up launcher and shell integration + - name: Install Claude Code (Ubuntu) + ansible.builtin.apt: + name: claude-code + state: present + update_cache: true + when: + - ansible_facts['distribution'] == 'Ubuntu' + - not soe_claude_repo_pending + + - name: Install Claude Code (Fedora) + ansible.builtin.dnf: + name: claude-code + state: present + when: + - ansible_facts['distribution'] == 'Fedora' + - not soe_claude_repo_pending + + # The native copy in ~/.local/bin precedes /usr/bin on the PATH, so it goes once the package copy runs. + - name: Check the packaged Claude Code runs ansible.builtin.command: - cmd: /tmp/claude-install/claude install + argv: [/usr/bin/claude, --version] become: true become_user: "{{ actual_user }}" - environment: - HOME: "{{ user_home }}" - register: claude_install_result - changed_when: claude_install_result.rc == 0 + register: soe_claude_package_check + changed_when: false + failed_when: false + check_mode: false - - name: Remove temporary download directory + - name: Inspect the native Claude Code launcher + ansible.builtin.stat: + path: "{{ user_home }}/.local/bin/claude" + follow: false + register: soe_claude_native_launcher + + # A launcher linking into the native installer's versions directory is the native install; any other file there is left alone. + - name: Decide whether a native Claude Code install is to be replaced + ansible.builtin.set_fact: + soe_claude_native_found: >- + {{ soe_claude_package_check.rc == 0 + and soe_claude_native_launcher.stat.islnk | default(false) + and soe_claude_native_launcher.stat.lnk_target is match('^' ~ (user_home ~ '/.local/share/claude/versions/') | regex_escape) }} + + # The staging and locks directories belong to the native updater, which the package build does not run. + - name: Remove the native Claude Code install the package replaces ansible.builtin.file: - path: /tmp/claude-install + path: "{{ item }}" state: absent + loop: + - "{{ user_home }}/.local/bin/claude" + - "{{ user_home }}/.local/share/claude" + - "{{ user_home }}/.cache/claude/staging" + - "{{ user_home }}/.local/state/claude/locks" + when: soe_claude_native_found | bool - - name: Display Claude Code installation info - ansible.builtin.debug: - msg: "Claude Code {{ claude_version }} installed ({{ claude_platform }})" + # A top-level "installMethod": "native" makes `claude doctor` offer `claude install`, + # which would restore the shadowing copy; failed_when covers a user with no config file. + - name: Clear the native install method from the user's Claude Code config + ansible.builtin.replace: + path: "{{ user_home }}/.claude.json" + regexp: '^ "installMethod": "native",\n' + replace: '' + failed_when: false + when: + - soe_claude_package_check.rc == 0 + - not soe_claude_native_launcher.stat.exists or soe_claude_native_found | bool - # Check mode previews the download, so `claude install` runs against a binary - # that is not there and the whole soe run would otherwise stop at a preview. rescue: + # Deleted as a file on a key mismatch, leaving the trusted key: deb822_repository state=absent would delete that keyring too. + - name: Remove the Claude Code apt repository after a key mismatch (Ubuntu) + ansible.builtin.file: + path: "{{ hyperi_exclusive_apt_sources.claude.file }}" + state: absent + when: + - ansible_failed_task.action in ['assert', 'ansible.builtin.assert'] + - ansible_facts['distribution'] == 'Ubuntu' + + - name: Remove the Claude Code repository after a key mismatch (Fedora) + ansible.builtin.yum_repository: + name: claude-code + state: absent + when: + - ansible_failed_task.action in ['assert', 'ansible.builtin.assert'] + - ansible_facts['distribution'] == 'Fedora' + + - name: Remove the Claude Code apt source if apt reported a conflicting key (Ubuntu) + ansible.builtin.include_role: + name: system_cleanup + tasks_from: apt_source_exclusive.yml + apply: + tags: ['always'] + vars: + system_cleanup_apt_source_name: claude + system_cleanup_apt_source_failed: "{{ ansible_failed_result }}" + when: ansible_facts['distribution'] == 'Ubuntu' + - name: Record that Claude Code did not install (Linux) + # noqa: var-naming[no-role-prefix] -- deploy_warnings is the shared accumulator ansible.builtin.set_fact: deploy_warnings: >- {{ deploy_warnings | default([]) + ['Claude Code: ' ~ (ansible_failed_task.name | default('unknown task')) ~ ' -- ' ~ (ansible_failed_result.msg | default('no message'))] }} - when: ansible_facts['distribution'] in ['Fedora', 'Ubuntu'] - # ============================================================================ # macOS - Homebrew cask installation # ============================================================================ diff --git a/ansible/roles/soe/tasks/verify.yml b/ansible/roles/soe/tasks/verify.yml index 1cc26b2..752850c 100644 --- a/ansible/roles/soe/tasks/verify.yml +++ b/ansible/roles/soe/tasks/verify.yml @@ -24,11 +24,11 @@ failed_when: verify_openvpn.rc != 0 when: ansible_facts['distribution'] == 'MacOSX' -# Run as the user whose home this is, not as whoever connected. `become: false` -# means the connection user, and on a fleet machine that is a service account -# (hyperi-infra connects as `ubuntu`) with no access to the desktop user's home. +# Run as the desktop user, not whoever connected: on a fleet machine the +# connection user is a service account (hyperi-infra connects as `ubuntu`), and +# claude would start up against that account's home instead. - name: Verify Claude Code CLI (Linux) - ansible.builtin.command: "{{ user_home }}/.local/bin/claude --version" + ansible.builtin.command: /usr/bin/claude --version register: verify_claude changed_when: false failed_when: verify_claude.rc != 0 diff --git a/ansible/roles/system_cleanup/tasks/apt_source_exclusive.yml b/ansible/roles/system_cleanup/tasks/apt_source_exclusive.yml new file mode 100644 index 0000000..01ab5e6 --- /dev/null +++ b/ansible/roles/system_cleanup/tasks/apt_source_exclusive.yml @@ -0,0 +1,90 @@ +--- +# Keep one apt source per repository, so apt can always read its sources. +# +# apt refuses to read ANY source once two entries for one repository carry +# different Signed-By values, which is what `add-apt-repository` or a vendor's +# own install instructions leave beside the .sources a role writes. +# +# Inputs: +# system_cleanup_apt_source_name key into hyperi_exclusive_apt_sources +# (group_vars/all.yml), which holds the +# repository regex and our .sources file +# system_cleanup_apt_source_if_present (optional) act only while that file +# exists, for a pass that runs before the +# owning role decides whether to write it +# system_cleanup_apt_source_failed (optional) the failed result from a +# rescue; when apt reported the conflict, +# the role's own file goes instead + +- name: Look up the apt source entry, {{ system_cleanup_apt_source_name }} + ansible.builtin.set_fact: + system_cleanup_apt_source_match: "{{ hyperi_exclusive_apt_sources[system_cleanup_apt_source_name].match }}" + system_cleanup_apt_source_file: "{{ hyperi_exclusive_apt_sources[system_cleanup_apt_source_name].file }}" + +- name: Check for the role's own source, {{ system_cleanup_apt_source_file | basename }} + ansible.builtin.stat: + path: "{{ system_cleanup_apt_source_file }}" + register: system_cleanup_apt_source_own + when: system_cleanup_apt_source_if_present | default(false) | bool + +- name: Decide whether to sweep other sources, {{ system_cleanup_apt_source_file | basename }} + ansible.builtin.set_fact: + system_cleanup_apt_source_sweep: >- + {{ system_cleanup_apt_source_failed is not defined + and (not system_cleanup_apt_source_if_present | default(false) | bool + or system_cleanup_apt_source_own.stat.exists | default(false)) }} + +- name: Find other apt entries for {{ system_cleanup_apt_source_file | basename }} + ansible.builtin.find: + paths: /etc/apt/sources.list.d + patterns: ['*.list', '*.sources'] + contains: '^\s*(deb(-src)?\s|URIs:).*{{ system_cleanup_apt_source_match }}' + become: true + register: system_cleanup_apt_source_others + when: system_cleanup_apt_source_sweep | bool + +- name: Remove other apt sources for {{ system_cleanup_apt_source_file | basename }} + ansible.builtin.file: + path: "{{ item }}" + state: absent + become: true + loop: >- + {{ system_cleanup_apt_source_others.files | default([]) | map(attribute='path') + | reject('equalto', system_cleanup_apt_source_file) | list }} + register: system_cleanup_apt_source_removed + when: system_cleanup_apt_source_sweep | bool + +- name: Remove sources.list entries for {{ system_cleanup_apt_source_file | basename }} + ansible.builtin.lineinfile: + path: /etc/apt/sources.list + regexp: '^\s*deb(-src)?\s.*{{ system_cleanup_apt_source_match }}' + state: absent + become: true + register: system_cleanup_apt_source_list_edit + when: system_cleanup_apt_source_sweep | bool + +- name: Report the apt sources replaced by {{ system_cleanup_apt_source_file | basename }} + # noqa: var-naming[no-role-prefix] -- deploy_warnings is the shared accumulator + ansible.builtin.set_fact: + deploy_warnings: >- + {{ (deploy_warnings | default([]) + + ['Removed ' ~ _gone | join(', ') ~ ': it named the same repository as ' + ~ system_cleanup_apt_source_file ~ ', and two entries with different keys stop apt reading any source.']) + | unique }} + vars: + _gone: >- + {{ system_cleanup_apt_source_removed.results | default([]) | selectattr('changed') | map(attribute='item') | list + + (['a line in /etc/apt/sources.list'] if system_cleanup_apt_source_list_edit is changed else []) }} + when: + - system_cleanup_apt_source_sweep | bool + - _gone | length > 0 + +- name: Remove the role's own source after apt reported a conflicting key, {{ system_cleanup_apt_source_file | basename }} + ansible.builtin.file: + path: "{{ system_cleanup_apt_source_file }}" + state: absent + become: true + when: + - system_cleanup_apt_source_failed is defined + - "'Conflicting values set for option Signed-By' in + (system_cleanup_apt_source_failed.msg | default('') ~ system_cleanup_apt_source_failed.stderr | default(''))" diff --git a/ansible/roles/vpn-clients/tasks/openvpn3.yml b/ansible/roles/vpn-clients/tasks/openvpn3.yml index 7c9d543..404d5c5 100644 --- a/ansible/roles/vpn-clients/tasks/openvpn3.yml +++ b/ansible/roles/vpn-clients/tasks/openvpn3.yml @@ -7,10 +7,16 @@ - name: Install OpenVPN 3 (Fedora) block: + # dnf5's own copr command, because community.general.copr needs dnf4's python3-dnf, which Fedora Server, cloud and minimal images lack. + - name: Ensure the dnf5 copr plugin is present + ansible.builtin.dnf: + name: dnf5-plugins + state: present + - name: Enable OpenVPN 3 COPR repository - community.general.copr: - name: dsommers/openvpn3 - state: enabled + ansible.builtin.command: + argv: [dnf, -y, copr, enable, dsommers/openvpn3] + creates: /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:dsommers:openvpn3.repo - name: Install OpenVPN 3 client ansible.builtin.dnf: diff --git a/docs/install-matrix.md b/docs/install-matrix.md index fda0f33..fe99b7e 100644 --- a/docs/install-matrix.md +++ b/docs/install-matrix.md @@ -115,9 +115,7 @@ three hard dependencies - Claude Code, the codex binary, node - and declares none of them, because declaring them would be wrong twice over. Claude Code comes only from `soe`, so depending on it drags HyperI org policy onto a box that asked for a review tool; node comes from the `developer` base rather than -`developer-node`, so the dependency that looks right supplies nothing. Claude -Code is also per-USER on Linux, which no role graph can answer. The run probes -the target for all three instead and names the tag that fixes each miss. +`developer-node`, so the dependency that looks right supplies nothing. Whether Claude Code is there depends on what the target user's PATH reaches, which no role graph can answer. The run probes the target for all three instead and names the tag that fixes each miss. ## Groups - themed tag bundles inside a role @@ -146,13 +144,7 @@ from their publisher and verify it. The rest rest on HTTPS and the release tag, which a publisher can in principle force-move. Extending checksum-at-download to the other manual binaries is the outstanding hardening (hyperi-ci #66). -Four fetches verify a digest IN THIS REPO, and they are the ones whose SHA is -held here or read from a published manifest: the Go toolchain, rustup-init, the -Claude Code binary, and macbash (whose `.sha256` sits beside the asset on -downloads.hyperi.io, so the fetch also re-pulls a republished binary). A fifth, -the Codex CLI on Linux, DELEGATES the check to a vendor script this repo runs -unverified. That is a weaker position than the other four and is counted -separately on purpose. +Three fetches verify a digest IN THIS REPO, and they are the ones whose SHA is held here or read from a published manifest: the Go toolchain, rustup-init, and macbash (whose `.sha256` sits beside the asset on downloads.hyperi.io, so the fetch also re-pulls a republished binary). A fourth, the Codex CLI on Linux, DELEGATES the check to a vendor script this repo runs unverified. That is a weaker position than the other three and is counted separately on purpose. Claude Code on Linux comes from Anthropic's signed apt/dnf repository, whose key is pinned by fingerprint like the other vendor repos. Codex is the one where what is verified matters. The installer script has no published checksum of its own; what it verifies is its payload, the @@ -185,7 +177,7 @@ reach for instead, are not in that manifest and have no published digest at all. |---|---|---| | VS Code | all | vendor-repo / cask | | Ghostty | Fedora (COPR) / macOS (cask) | vendor-repo / cask | -| Ghostty | Ubuntu | github-binary (.deb) | +| Ghostty | Ubuntu | PPA (`ppa:mkasberg/ghostty-ubuntu`, key pinned by fingerprint); 24.04 is frozen at Ghostty 1.3.1 by the PPA, only 26.04 tracks new releases; a series the PPA does not publish takes the newest series it does | | DBeaver | all | Ubuntu vendor-repo (dbeaver.io/debs) / Fedora flatpak / cask | | VS Code privacy profile (opt-in: `-e vscode_privacy_enabled=true`) | all | bundled script (`hyperi-vscode-privacy`) | @@ -374,7 +366,7 @@ hyperi-ci. | Tool(s) | Platforms | Method | |---|---|---| -| Claude Code CLI (binary, tag `claude`) | Linux github-binary (SHA-verified) / macOS cask | github-binary / cask | +| Claude Code CLI (binary, tag `claude`) | Linux Anthropic apt/dnf repo, `soe_claude_channel` (default `stable`; key pinned by fingerprint) / macOS cask | vendor-repo / cask | | Claude Code managed settings (tag `claude-policy`, soe only) | all | role file -> `/etc/claude-code/` | | tea (Forgejo/Gitea CLI, `forgejo` / `codeberg` tags; `gh` is GitHub-only) | Linux github-binary / macOS brew | github-binary / brew | | openvpn3 client (-> vpn-clients group) | Fedora COPR / Ubuntu vendor-repo / macOS brew | vendor-repo / brew | @@ -654,9 +646,7 @@ resolves the current release, verifies the payload digest and short-circuits when what is staged is already current. `hyperi-update` does exactly that, guarded on codex being present. -The Codex plugin is a channel of its own again: `claude plugin update -codex@openai-codex`, which `claude update` does not reach because that moves the -CLI and not its marketplace plugins. +The Codex plugin is a channel of its own again: `claude plugin update codex@openai-codex`, which the package manager does not reach because it moves the CLI and not its marketplace plugins. `hyperi-update` (the "update my system" command) runs all three tiers and the two out-of-tier channels above, plus the OS / snap / flatpak sweep, so one