diff --git a/roles/github_runner_macos_host/README.md b/roles/github_runner_macos_host/README.md index 314013ca..2c43b0a3 100644 --- a/roles/github_runner_macos_host/README.md +++ b/roles/github_runner_macos_host/README.md @@ -33,15 +33,15 @@ A MacBook with its lid closed still sleeps with `sleep 0`, because that setting ## Tailscale -The role installs the Homebrew formula `tailscale` and runs `tailscaled install-system-daemon`, which copies `tailscaled` to `/usr/local/bin`, writes the launchd daemon `/Library/LaunchDaemons/com.tailscale.tailscaled.plist` and starts it, so the daemon runs from boot, before anyone logs in. The standalone Tailscale app and this daemon are meant to run side by side on a Mac (the app's network extension and the daemon are both connected on the fleet's Macs), so the app being installed never skips the daemon. A host that already has that file is not installed on again, whether this role or a person installed it, and a daemon without the Homebrew formula behind it (one built by hand) is never replaced; what the role does for a host that has the formula is in Keeping Tailscale current below. +The role installs the Homebrew formula `tailscale` and runs `tailscaled install-system-daemon`, which copies `tailscaled` to `/usr/local/bin`, writes the launchd daemon `/Library/LaunchDaemons/com.tailscale.tailscaled.plist` and starts it, so the daemon runs from boot, before anyone logs in. The standalone Tailscale app and this daemon are meant to run side by side on a Mac (the app's network extension and the daemon are both connected on the fleet's Macs), so the app being installed never skips the daemon. The role runs the formula's binaries from the formula's keg (`/opt/tailscale/bin`, Homebrew's stable path for the newest keg) and never from the prefix's `bin` directory, because Homebrew links the formula there only when nothing else holds the names, and the standalone app's wrapper script (`#!/bin/sh` then a call of the app's own binary) holds `/usr/local/bin/tailscale` on an Intel Mac that has the app: Homebrew then installs the keg, removes every link it made and exits non-zero, which the role does not count as a failure while the formula is installed (and current, when it is being kept current). That wrapper also talks to the app and not to the daemon, which is why the role asks the daemon through the formula's own `tailscale`. A host that already has that file is not installed on again, whether this role or a person installed it, and a daemon without the Homebrew formula behind it (one built by hand) is never replaced; what the role does for a host that has the formula is in Keeping Tailscale current below. -The role never joins or re-authenticates a tailnet on its own, because joining needs an auth key, a secret only the tailnet's owner can create. With `github_runner_macos_host_tailscale_auth_key` set, the role reads the daemon's login state and, for a daemon that needs a login, runs `tailscale up` with the key on standard input (so it is in no process listing and, with `no_log`, in no output), plus `github_runner_macos_host_tailscale_up_args`. A daemon that is already logged in is not touched, and a join that failed is retried by the next run. It applies only to a host whose Homebrew formula the role installed (the formula's `tailscale` is in the Homebrew prefix), so a hand-built daemon is never joined or logged in again. +The role never joins or re-authenticates a tailnet on its own, because joining needs an auth key, a secret only the tailnet's owner can create. With `github_runner_macos_host_tailscale_auth_key` set, the role reads the daemon's login state and, for a daemon that needs a login, runs `tailscale up` with the key on standard input (so it is in no process listing and, with `no_log`, in no output), plus `github_runner_macos_host_tailscale_up_args`. A daemon that is already logged in is not touched, and a join that failed is retried by the next run. It applies only to a host whose Homebrew formula the role installed (the formula's `tailscale` is in its keg), so a hand-built daemon is never joined or logged in again. Without the key, join the host by hand once: `sudo tailscale up`, or run the role again with a key. ### Keeping Tailscale current -A Tailscale that was installed once and never updated falls behind: the formula is only installed when absent, and `tailscaled install-system-daemon` copies the formula's `tailscaled` to `/usr/local/bin` (or, on a prefix of `/usr/local` where that path is a link to the formula, starts it once), so upgrading the formula alone leaves the old release running. With `github_runner_macos_host_tailscale_update` (default true) the role, after the install above and before any join, does three things for what the host already has, and installs nothing: it refreshes Homebrew's index (`brew update`) and brings the `tailscale` formula, and no other formula, to its latest release; it runs `tailscaled install-system-daemon` again, once, when the installed daemon's release differs from the formula's or the running daemon's from the formula's, which removes the installed daemon and its launchd definition and installs and starts the new ones ([`cmd/tailscaled/install_darwin.go`](https://github.com/tailscale/tailscale/blob/main/cmd/tailscaled/install_darwin.go), whose uninstall step runs first), and the daemon keeps its login because that lives in its state directory; and when Tailscale's standalone app is installed as the Homebrew cask `tailscale-app`, it keeps that current too (`greedy`, because the app updates itself and Homebrew then lists it as outdated only when asked to be), never installing it. +A Tailscale that was installed once and never updated falls behind: the formula is only installed when absent, and `tailscaled install-system-daemon` copies the formula's `tailscaled` to `/usr/local/bin` (or, on a prefix of `/usr/local` where that path is a link to the formula, starts it once), so upgrading the formula alone leaves the old release running. With `github_runner_macos_host_tailscale_update` (default true) the role, after the install above and before any join, does three things for what the host already has, and installs nothing: it refreshes Homebrew's index (`brew update`) and brings the `tailscale` formula, and no other formula, to its latest release (only when Homebrew reports it behind, and an upgrade whose link step failed but which left the formula installed and current is a success); it runs `tailscaled install-system-daemon` again, once, when the installed daemon's release differs from the formula's or the running daemon's from the formula's, which removes the installed daemon and its launchd definition and installs and starts the new ones ([`cmd/tailscaled/install_darwin.go`](https://github.com/tailscale/tailscale/blob/main/cmd/tailscaled/install_darwin.go), whose uninstall step runs first), and the daemon keeps its login because that lives in its state directory; and when Tailscale's standalone app is installed as the Homebrew cask `tailscale-app`, it keeps that current too, never installing it: the app updates itself, so Homebrew's record of the cask lags and `brew outdated --greedy` lists it although the app is current, and upgrading it then runs the cask's installer over the current app, which fails and is reverted. The role therefore upgrades the cask only when the release in the app bundle (`CFBundleShortVersionString`) is behind the cask's, and leaves an app that has updated itself, or is ahead of the cask, alone. Replacing the daemon restarts it, so connections over the host's tailnet, an SSH session included, drop for a moment; upgrading the app runs its installer, which uses `sudo`, and restarts its network extension. A host with neither the formula behind its daemon nor the cask is skipped with a message, as is one without Homebrew, and a daemon built by hand is left as it is. Under `--check` Homebrew's index is not refreshed, so the answer is the one the index the host already has gives, nothing that changes the host runs, and the upgrade and the refresh are reported as changes. The role never joins or logs in to a tailnet, whatever it updates. @@ -53,11 +53,11 @@ Replacing the daemon restarts it, so connections over the host's tailnet, an SSH - `github_runner_macos_host_power_ac_sleep`, `github_runner_macos_host_power_ac_displaysleep`, `github_runner_macos_host_power_ac_disksleep` (default 0 each): minutes of inactivity before the system sleeps, the display sleeps and the disks spin down on AC power; 0 never. - `github_runner_macos_host_power_ac_womp`, `github_runner_macos_host_power_ac_standby`, `github_runner_macos_host_power_ac_powernap`, `github_runner_macos_host_power_ac_tcpkeepalive` (default 1 each): wake for network access, standby, Power Nap and keeping TCP connections alive during sleep; 1 on, 0 off. - `github_runner_macos_host_disable_sleep` (default true): keep the Mac awake with its lid closed, with `pmset -a disablesleep 1`. False leaves the setting alone and never turns it off. See Power settings above. -- `github_runner_macos_host_homebrew_prefix` (default empty): the Homebrew prefix, whose `bin` holds the formula's `tailscaled` and `tailscale`. Empty takes `/opt/homebrew` on Apple silicon and `/usr/local` on Intel. +- `github_runner_macos_host_homebrew_prefix` (default empty): the Homebrew prefix, whose `opt/tailscale/bin` holds the formula's `tailscaled` and `tailscale`. Empty takes `/opt/homebrew` on Apple silicon and `/usr/local` on Intel. - `github_runner_macos_host_tailscale_auth_key` (default empty), `github_runner_macos_host_tailscale_up_args` (default empty list): see Tailscale above. - `github_runner_macos_host_tailscale_plist`: the launchd definition `tailscaled install-system-daemon` writes. Not a setting; a variable only so the tests can point it at a stand-in. - `github_runner_macos_host_tailscale_update` (default true): keep the formula, its daemon and the standalone app current, as under Keeping Tailscale current above. Part of the Tailscale part, so `github_runner_macos_host_tailscale` false skips it too. -- `github_runner_macos_host_tailscale_daemon_binary`, `github_runner_macos_host_tailscale_app_cask`: where `tailscaled install-system-daemon` puts the daemon the launchd definition starts, and the Homebrew cask of the standalone app. Not settings; variables only so the tests can point them at stand-ins. +- `github_runner_macos_host_tailscale_daemon_binary`, `github_runner_macos_host_tailscale_app_cask`, `github_runner_macos_host_tailscale_app_bundle`: where `tailscaled install-system-daemon` puts the daemon the launchd definition starts, the Homebrew cask of the standalone app and where the cask puts the app, whose bundle says which release is installed. Not settings; variables only so the tests can point them at stand-ins. - `github_runner_macos_host_path`: the `PATH` the Homebrew tasks run with, both Homebrew prefixes and the system directories. `meta/argument_specs.yml` checks every variable when the role runs, and `defaults/main.yml` holds each default with its reasoning. @@ -79,4 +79,4 @@ github_runner_macos_host_tailscale_up_args: [--hostname=example] ## Testing -`tests/unit/test_macos_host.py` covers the filter in `plugins/filter/macos_host.py` that compares `pmset -g custom` with the wanted values, the defaults against the argument specs and this file, the sudoers file's rendered content, ownership and `visudo` validation, the wiring in `playbooks/site.yml` (the role runs before the fleet play, and `become` appears nowhere else), and the role's tasks (`tests/macos_host/run.yml`) against stand-ins for `pmset`, `brew`, `tailscaled`, `tailscale` and `sudo` on `PATH`. The stand-ins that change the host refuse to run without the `sudo` stand-in, as the real ones do for a user who is not root, so the tests check that exactly the tasks that need it use it. They check that a Mac that sleeps gets only the settings that differ, `disablesleep` is set once and then left alone (and not read or set at all when its variable is false), a second run and `--check` change nothing, a host that already has the daemon is left alone, an older formula and daemon get one upgrade of the formula alone and one refresh of the daemon (a current one gets neither, and `--check` makes neither), the standalone app is upgraded only where it is installed, and the auth key reaches `tailscale up` on standard input and nowhere else. +`tests/unit/test_macos_host.py` covers the filter in `plugins/filter/macos_host.py` that compares `pmset -g custom` with the wanted values, the defaults against the argument specs and this file, the sudoers file's rendered content, ownership and `visudo` validation, the wiring in `playbooks/site.yml` (the role runs before the fleet play, and `become` appears nowhere else), and the role's tasks (`tests/macos_host/run.yml`) against stand-ins for `pmset`, `brew`, `tailscaled`, `tailscale` and `sudo` on `PATH`. The stand-ins that change the host refuse to run without the `sudo` stand-in, as the real ones do for a user who is not root, so the tests check that exactly the tasks that need it use it. They check that a Mac that sleeps gets only the settings that differ, `disablesleep` is set once and then left alone (and not read or set at all when its variable is false), a second run and `--check` change nothing, a host that already has the daemon is left alone, an older formula and daemon get one upgrade of the formula alone and one refresh of the daemon (a current one gets neither, and `--check` makes neither), a formula whose link step fails on the app's wrapper script is still installed, upgraded and run from its keg while a formula that did not install fails the run with Homebrew's output, the standalone app is upgraded only where it is installed and its bundle is behind the cask (one that has updated itself is left alone, and a cask installer that fails for an app that is behind fails the run), and the auth key reaches `tailscale up` on standard input and nowhere else. diff --git a/roles/github_runner_macos_host/defaults/main.yml b/roles/github_runner_macos_host/defaults/main.yml index afebf696..e2e88f8e 100644 --- a/roles/github_runner_macos_host/defaults/main.yml +++ b/roles/github_runner_macos_host/defaults/main.yml @@ -30,15 +30,17 @@ github_runner_macos_host_disable_sleep: true # The host's own Tailscale client, installed from the Homebrew formula and run as the tailscaled system daemon (a launchd daemon that starts at boot, before anyone logs in, so the host stays reachable on its tailnet). The role installs and starts the daemon and never joins a tailnet unless github_runner_macos_host_tailscale_auth_key is set. A host whose daemon is already installed, by this role or by hand, is left entirely alone. github_runner_macos_host_tailscale: true -# The Homebrew prefix, whose bin directory holds the formula's tailscaled and tailscale. Empty takes /opt/homebrew on Apple silicon and /usr/local on Intel, from the host's machine fact. +# The Homebrew prefix, whose opt directory holds the formula's keg and so its tailscaled and tailscale, which the role runs from there whether or not Homebrew linked them into the prefix's bin directory. Empty takes /opt/homebrew on Apple silicon and /usr/local on Intel, from the host's machine fact. github_runner_macos_host_homebrew_prefix: "" # Not a setting: tailscaled install-system-daemon writes exactly this file, and its presence is how the role knows the daemon is installed. It is a variable only so the tests can point it at a stand-in. github_runner_macos_host_tailscale_plist: /Library/LaunchDaemons/com.tailscale.tailscaled.plist -# Whether the role keeps the Tailscale software it finds current. The Homebrew formula is brought to its latest release (the formula and no other), and when the installed daemon differs from the formula's release, or the running daemon from the installed one, tailscaled install-system-daemon is run again so the daemon runs the new release: the daemon is a copy of the formula's binary (or a link to it, started once), so upgrading the formula alone leaves the old one running. Tailscale's standalone app, when it is installed as the Homebrew cask, is kept current as well; it is never installed. A host with none of them is skipped with a message, and the role never joins or logs in to a tailnet. Replacing the daemon restarts it, which drops the host's tailnet connections for a moment (its login is kept), and upgrading the app restarts its network extension. Part of the Tailscale part, so github_runner_macos_host_tailscale false skips it too. +# Whether the role keeps the Tailscale software it finds current. The Homebrew formula is brought to its latest release (the formula and no other; a keg Homebrew installed but could not link, because the standalone app's wrapper script holds /usr/local/bin/tailscale, is installed all the same, and the role runs the formula from its keg), and when the installed daemon differs from the formula's release, or the running daemon from the installed one, tailscaled install-system-daemon is run again so the daemon runs the new release: the daemon is a copy of the formula's binary (or a link to it, started once), so upgrading the formula alone leaves the old one running. Tailscale's standalone app, when it is installed as the Homebrew cask, is kept current as well, but only when its own bundle is behind the cask's release, because the app updates itself and Homebrew's record of it then lags; it is never installed. A host with none of them is skipped with a message, and the role never joins or logs in to a tailnet. Replacing the daemon restarts it, which drops the host's tailnet connections for a moment (its login is kept), and upgrading the app restarts its network extension. Part of the Tailscale part, so github_runner_macos_host_tailscale false skips it too. github_runner_macos_host_tailscale_update: true # Not settings: where tailscaled install-system-daemon puts the daemon that the launchd definition starts, and the Homebrew cask of Tailscale's standalone app. Variables only so the tests can point them at stand-ins. github_runner_macos_host_tailscale_daemon_binary: /usr/local/bin/tailscaled github_runner_macos_host_tailscale_app_cask: tailscale-app +# Not a setting: where the cask puts the standalone app, whose bundle says which release is installed (the app updates itself, so Homebrew's own record can be older). A variable only so the tests can point it at a stand-in. +github_runner_macos_host_tailscale_app_bundle: /Applications/Tailscale.app # A Tailscale auth key (a secret only the tailnet's owner can create, from any source Ansible reads), empty by default. Set, the role runs tailscale up with it on a host whose daemon the role manages (the Homebrew formula's tailscale is installed) and whose tailnet login is still needed; empty, the role never joins or re-authenticates a tailnet. github_runner_macos_host_tailscale_auth_key: "" # Further arguments for tailscale up, such as --hostname=example or --advertise-tags=tag:example. Used only when the auth key is set. diff --git a/roles/github_runner_macos_host/meta/argument_specs.yml b/roles/github_runner_macos_host/meta/argument_specs.yml index 78be1ff5..bd0778cb 100644 --- a/roles/github_runner_macos_host/meta/argument_specs.yml +++ b/roles/github_runner_macos_host/meta/argument_specs.yml @@ -73,7 +73,7 @@ argument_specs: github_runner_macos_host_tailscale_update: type: bool default: true - description: "Whether the role keeps the Tailscale software it finds current: the Homebrew formula tailscale at its latest release, the system daemon refreshed (tailscaled install-system-daemon) when it differs from the formula or the running daemon differs from the installed one, and the standalone app too when it is installed as the Homebrew cask. Nothing is installed that is not already, and no tailnet is joined. Skipped when github_runner_macos_host_tailscale is false." + description: "Whether the role keeps the Tailscale software it finds current: the Homebrew formula tailscale at its latest release (a keg that Homebrew installed but could not link into the prefix counts), the system daemon refreshed (tailscaled install-system-daemon) when it differs from the formula or the running daemon differs from the installed one, and the standalone app too when it is installed as the Homebrew cask and its bundle is behind the cask's release. Nothing is installed that is not already, and no tailnet is joined. Skipped when github_runner_macos_host_tailscale is false." github_runner_macos_host_tailscale_daemon_binary: type: path default: /usr/local/bin/tailscaled @@ -82,6 +82,10 @@ argument_specs: type: str default: tailscale-app description: "The Homebrew cask of Tailscale's standalone app, kept current only when it is installed. Not a setting, and a variable only so the tests can point it at a stand-in." + github_runner_macos_host_tailscale_app_bundle: + type: path + default: /Applications/Tailscale.app + description: "Where the Homebrew cask puts Tailscale's standalone app, whose bundle says which release is installed, since the app updates itself and Homebrew's own record can be older. Not a setting, and a variable only so the tests can point it at a stand-in." github_runner_macos_host_tailscale_auth_key: type: str default: "" diff --git a/roles/github_runner_macos_host/tasks/tailscale.yml b/roles/github_runner_macos_host/tasks/tailscale.yml index 00b63a0a..52d4e289 100644 --- a/roles/github_runner_macos_host/tasks/tailscale.yml +++ b/roles/github_runner_macos_host/tasks/tailscale.yml @@ -8,6 +8,11 @@ if github_runner_macos_host_homebrew_prefix | length > 0 else ('/opt/homebrew' if ansible_facts['machine'] == 'arm64' else '/usr/local') }} +- name: Work out where the formula's binaries are + # The keg's own bin directory through Homebrew's stable opt path, not the prefix's bin directory: Homebrew links the formula there only when nothing else holds the names (the standalone app's wrapper script holds tailscale on a Mac that has the app), and the role needs the formula's binaries whether or not it is linked. + ansible.builtin.set_fact: + github_runner_macos_host_tailscale_formula_bin: "{{ github_runner_macos_host_effective_prefix }}/opt/tailscale/bin" + - name: Look for the Tailscale system daemon's launchd definition ansible.builtin.stat: path: "{{ github_runner_macos_host_tailscale_plist }}" @@ -18,18 +23,15 @@ when: not github_runner_macos_host_tailscale_daemon.stat.exists block: - name: Install the Tailscale Homebrew formula - community.general.homebrew: - name: tailscale - state: present - path: "{{ github_runner_macos_host_effective_prefix }}/bin" - environment: - PATH: "{{ github_runner_macos_host_path }}" + ansible.builtin.include_tasks: tailscale_formula.yml + vars: + github_runner_macos_host_tailscale_formula_latest: false - name: Install and start the tailscaled system daemon # Copies tailscaled to /usr/local/bin, writes the launchd definition and starts it, which takes root. Never joins a tailnet. become: true ansible.builtin.command: - argv: ["{{ github_runner_macos_host_effective_prefix }}/bin/tailscaled", install-system-daemon] + argv: ["{{ github_runner_macos_host_tailscale_formula_bin }}/tailscaled", install-system-daemon] creates: "{{ github_runner_macos_host_tailscale_plist }}" when: not ansible_check_mode @@ -47,8 +49,9 @@ # Joining needs an auth key, a secret only the tailnet's owner can create, so it happens only when one is given. The login state is read from the daemon, so a host that is already logged in, and a failed join, are both handled by rerunning. - name: Find the Tailscale command line the role installed + # The formula's, which talks to the tailscaled daemon: the prefix's tailscale can be the standalone app's wrapper, which talks to the app. ansible.builtin.stat: - path: "{{ github_runner_macos_host_effective_prefix }}/bin/tailscale" + path: "{{ github_runner_macos_host_tailscale_formula_bin }}/tailscale" register: github_runner_macos_host_tailscale_cli when: github_runner_macos_host_tailscale_auth_key | length > 0 @@ -61,7 +64,7 @@ block: - name: Read the daemon's login state ansible.builtin.command: - argv: ["{{ github_runner_macos_host_effective_prefix }}/bin/tailscale", status, --json, --peers=false] + argv: ["{{ github_runner_macos_host_tailscale_formula_bin }}/tailscale", status, --json, --peers=false] register: github_runner_macos_host_tailscale_status changed_when: false check_mode: false @@ -74,7 +77,7 @@ become: true ansible.builtin.command: argv: >- - {{ [github_runner_macos_host_effective_prefix ~ '/bin/tailscale', 'up', '--auth-key=file:/dev/stdin'] + {{ [github_runner_macos_host_tailscale_formula_bin ~ '/tailscale', 'up', '--auth-key=file:/dev/stdin'] + github_runner_macos_host_tailscale_up_args }} # The key is read from standard input, so it is in no process listing. stdin: "{{ github_runner_macos_host_tailscale_auth_key }}" diff --git a/roles/github_runner_macos_host/tasks/tailscale_formula.yml b/roles/github_runner_macos_host/tasks/tailscale_formula.yml new file mode 100644 index 00000000..884f41f9 --- /dev/null +++ b/roles/github_runner_macos_host/tasks/tailscale_formula.yml @@ -0,0 +1,78 @@ +--- +# Puts the tailscale Homebrew formula where the caller wants it: installed, or installed and at its latest release (github_runner_macos_host_tailscale_formula_latest), and sets github_runner_macos_host_tailscale_formula_changed to whether Homebrew had to install or upgrade it. Homebrew's link step is not part of what the role needs, because the role runs the formula's binaries from its keg (github_runner_macos_host_tailscale_formula_bin, the stable opt path, which Homebrew points at the newest keg before it links anything), never from the prefix's bin directory. That directory can hold the standalone app's wrapper script as /usr/local/bin/tailscale, and Homebrew then installs the keg, removes every link it made and exits non-zero ("The `brew link` step did not complete successfully"). So the outcome is judged by the formula that Homebrew reports afterwards and not by its exit status: a keg that is installed (and current, when asked) is a success whatever the link step said, and anything else fails with Homebrew's own output. + +- name: Read the formula's releases + ansible.builtin.command: + argv: ["{{ github_runner_macos_host_effective_prefix }}/bin/brew", info, --json=v2, tailscale] + environment: + PATH: "{{ github_runner_macos_host_path }}" + register: github_runner_macos_host_tailscale_formula_before + changed_when: false + check_mode: false + +- name: Work out what the formula needs + ansible.builtin.set_fact: + # An installed keg that is behind is outdated whether or not it is linked, so a keg that an earlier upgrade left unlinked is not upgraded again. + github_runner_macos_host_tailscale_formula_action: >- + {{ 'install' if (github_runner_macos_host_tailscale_formula_before.stdout | from_json).formulae[0].installed | length == 0 + else ('upgrade' if github_runner_macos_host_tailscale_formula_latest | bool + and (github_runner_macos_host_tailscale_formula_before.stdout | from_json).formulae[0].outdated | bool else '') }} + +- name: Install or upgrade the formula + # Only the tailscale formula is named, so no other formula is upgraded. The index was refreshed (or deliberately not) before this, so Homebrew is not to refresh it again. + ansible.builtin.command: + argv: ["{{ github_runner_macos_host_effective_prefix }}/bin/brew", "{{ github_runner_macos_host_tailscale_formula_action }}", tailscale] + environment: + PATH: "{{ github_runner_macos_host_path }}" + HOMEBREW_NO_AUTO_UPDATE: "1" + register: github_runner_macos_host_tailscale_formula_run + # Judged by the formula afterwards, below. + failed_when: false + changed_when: true + when: + - github_runner_macos_host_tailscale_formula_action | length > 0 + - not ansible_check_mode + +- name: Report the formula a real run would install or upgrade + ansible.builtin.debug: + msg: "brew {{ github_runner_macos_host_tailscale_formula_action }} tailscale" + changed_when: true + when: + - github_runner_macos_host_tailscale_formula_action | length > 0 + - ansible_check_mode + +- name: Read the formula's releases again + ansible.builtin.command: + argv: ["{{ github_runner_macos_host_effective_prefix }}/bin/brew", info, --json=v2, tailscale] + environment: + PATH: "{{ github_runner_macos_host_path }}" + register: github_runner_macos_host_tailscale_formula_after + changed_when: false + when: github_runner_macos_host_tailscale_formula_run is not skipped + +- name: Check that the formula is where it was wanted + ansible.builtin.assert: + that: + - (github_runner_macos_host_tailscale_formula_after.stdout | from_json).formulae[0].installed | length > 0 + - not (github_runner_macos_host_tailscale_formula_latest | bool and (github_runner_macos_host_tailscale_formula_after.stdout | from_json).formulae[0].outdated | bool) + fail_msg: >- + brew {{ github_runner_macos_host_tailscale_formula_action }} tailscale exited {{ github_runner_macos_host_tailscale_formula_run.rc }} + and left the formula {{ 'not installed' if (github_runner_macos_host_tailscale_formula_after.stdout | from_json).formulae[0].installed | length == 0 else 'outdated' }}: + {{ (github_runner_macos_host_tailscale_formula_run.stderr | trim) or (github_runner_macos_host_tailscale_formula_run.stdout | trim) }} + quiet: true + when: github_runner_macos_host_tailscale_formula_run is not skipped + +- name: Say that Homebrew did not link the formula + # The keg is what the role runs, so this is information and not a failure. + ansible.builtin.debug: + msg: >- + brew {{ github_runner_macos_host_tailscale_formula_action }} tailscale exited {{ github_runner_macos_host_tailscale_formula_run.rc }} after installing the formula, which is not linked into + {{ github_runner_macos_host_effective_prefix }}/bin (a file there is in the way, such as the standalone app's wrapper script); the role runs the formula from + {{ github_runner_macos_host_tailscale_formula_bin }}: {{ github_runner_macos_host_tailscale_formula_run.stderr | trim }} + when: + - github_runner_macos_host_tailscale_formula_run is not skipped + - github_runner_macos_host_tailscale_formula_run.rc != 0 + +- name: Note whether the formula changed + ansible.builtin.set_fact: + github_runner_macos_host_tailscale_formula_changed: "{{ github_runner_macos_host_tailscale_formula_action | length > 0 }}" diff --git a/roles/github_runner_macos_host/tasks/tailscale_update.yml b/roles/github_runner_macos_host/tasks/tailscale_update.yml index d3e5afd9..08f66380 100644 --- a/roles/github_runner_macos_host/tasks/tailscale_update.yml +++ b/roles/github_runner_macos_host/tasks/tailscale_update.yml @@ -9,8 +9,9 @@ register: github_runner_macos_host_tailscale_brew - name: Look for the formula's tailscaled + # In the keg, which is there whether or not Homebrew linked the formula into the prefix. ansible.builtin.stat: - path: "{{ github_runner_macos_host_effective_prefix }}/bin/tailscaled" + path: "{{ github_runner_macos_host_tailscale_formula_bin }}/tailscaled" get_checksum: false register: github_runner_macos_host_tailscale_formula @@ -62,19 +63,15 @@ when: github_runner_macos_host_tailscale_formula_managed | bool block: - name: Bring the Tailscale formula to its latest release - # Only the tailscale formula is named, so no other formula is upgraded. - community.general.homebrew: - name: tailscale - state: latest - path: "{{ github_runner_macos_host_effective_prefix }}/bin" - environment: - PATH: "{{ github_runner_macos_host_path }}" - register: github_runner_macos_host_tailscale_formula_upgrade + # Only the tailscale formula is named, so no other formula is upgraded, and only when Homebrew reports it behind. + ansible.builtin.include_tasks: tailscale_formula.yml + vars: + github_runner_macos_host_tailscale_formula_latest: true - name: Read the formula's release # The first line of tailscaled --version is the release alone. ansible.builtin.command: - argv: ["{{ github_runner_macos_host_effective_prefix }}/bin/tailscaled", --version] + argv: ["{{ github_runner_macos_host_tailscale_formula_bin }}/tailscaled", --version] register: github_runner_macos_host_tailscale_formula_version changed_when: false check_mode: false @@ -89,9 +86,9 @@ check_mode: false - name: Read the running daemon's release - # Where the daemon path is a link to the formula, the installed binary is the new release as soon as the formula is, and the process is still the old one. A daemon that does not answer reads as no release, and is compared by its binary alone. + # Where the daemon path is a link to the formula, the installed binary is the new release as soon as the formula is, and the process is still the old one. A daemon that does not answer reads as no release, and is compared by its binary alone. Asked through the formula's own tailscale, which talks to the tailscaled daemon: the prefix's tailscale can be the standalone app's wrapper, which talks to the app. ansible.builtin.command: - argv: ["{{ github_runner_macos_host_effective_prefix }}/bin/tailscale", status, --json, --peers=false] + argv: ["{{ github_runner_macos_host_tailscale_formula_bin }}/tailscale", status, --json, --peers=false] register: github_runner_macos_host_tailscale_running changed_when: false failed_when: false @@ -100,7 +97,7 @@ - name: Work out whether the daemon has to be replaced ansible.builtin.set_fact: github_runner_macos_host_tailscale_refresh_needed: >- - {{ github_runner_macos_host_tailscale_formula_upgrade is changed + {{ github_runner_macos_host_tailscale_formula_changed | bool or (github_runner_macos_host_tailscale_daemon_version.stdout_lines | first | default('') | trim) != (github_runner_macos_host_tailscale_formula_version.stdout_lines | first | trim) or (github_runner_macos_host_tailscale_running_version | length > 0 and github_runner_macos_host_tailscale_running_version != (github_runner_macos_host_tailscale_formula_version.stdout_lines | first | trim)) }} @@ -114,7 +111,7 @@ # Removes the installed daemon and its launchd definition and installs and starts the new ones, which takes root. Never joins a tailnet, and the daemon keeps its login. become: true ansible.builtin.command: - argv: ["{{ github_runner_macos_host_effective_prefix }}/bin/tailscaled", install-system-daemon] + argv: ["{{ github_runner_macos_host_tailscale_formula_bin }}/tailscaled", install-system-daemon] changed_when: true when: - github_runner_macos_host_tailscale_refresh_needed | bool @@ -128,13 +125,41 @@ - github_runner_macos_host_tailscale_refresh_needed | bool - ansible_check_mode -- name: Bring the standalone app to its latest release - # The app updates itself, so Homebrew does not list the cask as outdated unless asked to be greedy. Upgrading it runs its installer, which uses sudo. Only an installed cask is named here, because this module installs one that is absent. - community.general.homebrew_cask: - name: "{{ github_runner_macos_host_tailscale_app_cask }}" - state: latest - greedy: true - path: "{{ github_runner_macos_host_effective_prefix }}/bin" - environment: - PATH: "{{ github_runner_macos_host_path }}" +- name: Keep the standalone app current when: github_runner_macos_host_tailscale_app_installed | bool + block: + - name: Read the cask's release + ansible.builtin.command: + argv: ["{{ github_runner_macos_host_effective_prefix }}/bin/brew", info, --cask, --json=v2, "{{ github_runner_macos_host_tailscale_app_cask }}"] + environment: + PATH: "{{ github_runner_macos_host_path }}" + register: github_runner_macos_host_tailscale_app_cask_info + changed_when: false + check_mode: false + + - name: Read the installed app's release + # From the app bundle itself, because the app updates itself and Homebrew's record of the cask then says an older release than the one that is installed. An app that cannot be read reads as no release, so the cask puts it back. + ansible.builtin.command: + argv: [defaults, read, "{{ github_runner_macos_host_tailscale_app_bundle }}/Contents/Info", CFBundleShortVersionString] + register: github_runner_macos_host_tailscale_app_bundle_version + changed_when: false + failed_when: false + check_mode: false + + - name: Work out whether the app is behind the cask + # Behind, not different: an app that has updated itself past the cask is left as it is. Upgrading an app that is not behind would run the cask's installer over it, which fails. + ansible.builtin.set_fact: + github_runner_macos_host_tailscale_app_behind: >- + {{ github_runner_macos_host_tailscale_app_bundle_version.rc != 0 + or github_runner_macos_host_tailscale_app_bundle_version.stdout | trim is version((github_runner_macos_host_tailscale_app_cask_info.stdout | from_json).casks[0].version, '<') }} + + - name: Bring the standalone app to its latest release + # Upgrading it runs its installer, which uses sudo. Only an installed cask is named here, because this module installs one that is absent. Greedy, because the app updates itself and Homebrew then lists it as outdated only when asked to be. + community.general.homebrew_cask: + name: "{{ github_runner_macos_host_tailscale_app_cask }}" + state: latest + greedy: true + path: "{{ github_runner_macos_host_effective_prefix }}/bin" + environment: + PATH: "{{ github_runner_macos_host_path }}" + when: github_runner_macos_host_tailscale_app_behind | bool diff --git a/tests/unit/test_macos_host.py b/tests/unit/test_macos_host.py index 7f65a274..f048dcfa 100644 --- a/tests/unit/test_macos_host.py +++ b/tests/unit/test_macos_host.py @@ -28,6 +28,8 @@ SPECS: dict[str, Any] = yaml.safe_load((ROLE / "meta" / "argument_specs.yml").read_text())["argument_specs"]["main"]["options"] PLIST_NAME = "com.tailscale.tailscaled.plist" AUTH_KEY = "tskey-auth-example-0123456789" +# The standalone app's wrapper for its command line (the bundle's path is the test's stand-in app), which an Intel Mac that has the app holds /usr/local/bin/tailscale with, where Homebrew then cannot link the formula's tailscale. +APP_WRAPPER = '#!/bin/sh\n{bundle}/Contents/MacOS/Tailscale "$@"\n' _spec = importlib.util.spec_from_file_location("macos_host_filters", PLUGIN) assert _spec is not None and _spec.loader is not None @@ -168,16 +170,27 @@ def test_the_tailscale_software_is_kept_current_by_default_and_only_when_it_is_i self.assertIs(DEFAULTS["github_runner_macos_host_tailscale_update"], True) tasks = yaml.safe_load((ROLE / "tasks" / "tailscale_update.yml").read_text()) [formula] = [task for task in tasks if task.get("name") == "Keep the formula's daemon current"] - [upgrade] = [task for task in formula["block"] if "community.general.homebrew" in task] - # Only the tailscale formula is named, so no other formula is upgraded, and none is installed by this. - self.assertEqual((upgrade["community.general.homebrew"]["name"], upgrade["community.general.homebrew"]["state"]), ("tailscale", "latest")) + [upgrade] = [task for task in formula["block"] if task.get("ansible.builtin.include_tasks") == "tailscale_formula.yml"] + self.assertIs(upgrade["vars"]["github_runner_macos_host_tailscale_formula_latest"], True) self.assertIn("github_runner_macos_host_tailscale_formula_managed", formula["when"]) - [cask] = [task for task in tasks if "community.general.homebrew_cask" in task] - self.assertIn("github_runner_macos_host_tailscale_app_installed", cask["when"]) + # Only the tailscale formula is named, so no other formula is upgraded. + formula_tasks = yaml.safe_load((ROLE / "tasks" / "tailscale_formula.yml").read_text()) + [run] = [task for task in formula_tasks if task.get("name") == "Install or upgrade the formula"] + self.assertEqual(run["ansible.builtin.command"]["argv"][-1], "tailscale") + [app] = [task for task in tasks if task.get("name") == "Keep the standalone app current"] + self.assertIn("github_runner_macos_host_tailscale_app_installed", app["when"]) + [cask] = [task for task in app["block"] if "community.general.homebrew_cask" in task] + self.assertIn("github_runner_macos_host_tailscale_app_behind", cask["when"]) self.assertIs(cask["community.general.homebrew_cask"]["greedy"], True) include = [task for task in yaml.safe_load((ROLE / "tasks" / "tailscale.yml").read_text()) if task.get("ansible.builtin.include_tasks") == "tailscale_update.yml"] self.assertEqual([task["when"] for task in include], ["github_runner_macos_host_tailscale_update | bool"]) + def test_the_role_runs_the_formula_from_its_keg_and_never_from_the_prefixs_bin_directory(self) -> None: + # The prefix's bin directory can hold the standalone app's wrapper where the formula's link would go, so a binary named there may be the app's. + for name in ("tailscale.yml", "tailscale_update.yml", "tailscale_formula.yml"): + with self.subTest(name=name): + self.assertNotRegex((ROLE / "tasks" / name).read_text(), r"effective_prefix \}\}/bin/tailscale") + def test_the_role_never_joins_a_tailnet_by_default(self) -> None: self.assertEqual(DEFAULTS["github_runner_macos_host_tailscale_auth_key"], "") self.assertEqual(DEFAULTS["github_runner_macos_host_tailscale_up_args"], []) @@ -294,7 +307,7 @@ def test_bootstrap_does_not_ask_for_sudo(self) -> None: self.assertIn("github_runner_macos_host_tailscale: false", text) -# Stands in for pmset, brew, tailscaled, tailscale and sudo, which are all this one script under five names. State is files in $MACOS_HOST_STUB: pmset.ac (one "name value" line per AC Power setting), sleepdisabled (what pmset -g reports as SleepDisabled, absent when the Mac lists no such line), brew.installed, daemon.up and login.state (what tailscale status reports), LaunchDaemons/ (where tailscaled install-system-daemon writes its definition); every call is appended to calls.log as " root=<0|1> ". The sudo stand-in does what sudo does for the role, which is to run the command as root: it marks the command with MACOS_HOST_ROOT, and a stand-in that changes the host refuses to without it, as the real one does for a user who is not root. A key given to tailscale up is logged only as a hash of it. +# Stands in for pmset, brew, defaults, tailscaled, tailscale and sudo, which are all this one script under six names. State is files in $MACOS_HOST_STUB: pmset.ac (one "name value" line per AC Power setting), sleepdisabled (what pmset -g reports as SleepDisabled, absent when the Mac lists no such line), brew.installed, daemon.up and login.state (what tailscale status reports), LaunchDaemons/ (where tailscaled install-system-daemon writes its definition); every call is appended to calls.log as " root=<0|1> ". The formula's binaries are installed into the keg (opt/tailscale/bin under the Homebrew prefix) and linked into the prefix's bin directory, unless a file that is not a link is already there, as the standalone app's wrapper script is: then brew exits 1 with Homebrew's own output, having installed the keg and removed its links. formula.fail makes brew fail before installing anything, and cask.pkgfail makes the cask's installer fail as the Tailscale pkg's does, which Homebrew reverts. app.version is the release in the app bundle, which defaults reads. The sudo stand-in does what sudo does for the role, which is to run the command as root: it marks the command with MACOS_HOST_ROOT, and a stand-in that changes the host refuses to without it, as the real one does for a user who is not root. A key given to tailscale up is logged only as a hash of it. STUB = """#!{python} import hashlib, json, os, sys stub = os.environ["MACOS_HOST_STUB"] @@ -352,13 +365,34 @@ def need_root(): elif args[:2] == ["info", "--json=v2"] and args[2:3] == ["tailscale"]: installed = [{{"version": read("formula.installed")}}] if os.path.exists(path("formula.installed")) else [] print(json.dumps({{"formulae": [{{"name": "tailscale", "full_name": "tailscale", "tap": "homebrew/core", "installed": installed, "outdated": bool(installed) and read("formula.installed") != read("formula.latest", "1.2.0")}}], "casks": []}})) + elif args[:2] == ["info", "--cask"] and args[2:] == ["--json=v2", "tailscale-app"]: + print(json.dumps({{"formulae": [], "casks": [{{"token": "tailscale-app", "version": read("cask.latest", "1.2.0")}}]}})) elif args[:1] in (["install"], ["upgrade"]) and args[1:] == ["tailscale"]: + if os.path.exists(path("formula.fail")): + sys.exit("Error: tailscale: no bottle available!") write("formula.installed", read("formula.latest", "1.2.0")) bindir = os.path.dirname(os.path.abspath(sys.argv[0])) - for tool in ("tailscaled", "tailscale"): - target = os.path.join(bindir, tool) - open(target, "w").write(open(sys.argv[0]).read()) - os.chmod(target, 0o755) + kegbin = os.path.join(os.path.dirname(bindir), "opt", "tailscale", "bin") + os.makedirs(kegbin, exist_ok=True) + links = [] + collision = None + for tool in ("tailscale", "tailscaled"): + keg = os.path.join(kegbin, tool) + open(keg, "w").write(open(sys.argv[0]).read()) + os.chmod(keg, 0o755) + link = os.path.join(bindir, tool) + if os.path.islink(link): + os.unlink(link) + if os.path.lexists(link): + collision = collision or link + else: + os.symlink(keg, link) + links.append(link) + if collision: + for link in links: + os.unlink(link) + print("The formula built, but is not symlinked into " + os.path.dirname(bindir) + "\\nCould not symlink bin/tailscale\\nTarget " + collision + "\\nalready exists.") + sys.exit("Error: The `brew link` step did not complete successfully") elif args[:2] == ["list", "--cask"]: sys.exit(0 if os.path.exists(path("cask.installed")) else 1) elif args[:2] == ["outdated", "--cask"] and args[-1:] == ["tailscale-app"]: @@ -367,9 +401,23 @@ def need_root(): if read("cask.installed") != read("cask.latest", "1.2.0"): print("tailscale-app") elif args == ["upgrade", "--cask", "tailscale-app"]: + if os.path.exists(path("cask.pkgfail")): + sys.exit("Error: An error occurred while running scripts from the package Tailscale-" + read("cask.latest", "1.2.0") + "-macos.pkg.") write("cask.installed", read("cask.latest", "1.2.0")) + write("app.version", read("cask.latest", "1.2.0")) else: sys.exit("unexpected brew call: " + " ".join(args)) +elif name == "defaults": + log("defaults root=" + str(int(root)) + " " + " ".join(args)) + if args != ["read", os.environ["MACOS_HOST_APP"] + "/Contents/Info", "CFBundleShortVersionString"]: + sys.exit("unexpected defaults call: " + " ".join(args)) + if not os.path.exists(path("app.version")): + sys.exit("The domain/default pair of (" + args[1] + ", CFBundleShortVersionString) does not exist") + print(read("app.version")) +elif name == "Tailscale": + # The standalone app's own binary, which its wrapper script calls: it answers for the app and not for the tailscaled daemon, so the role has no use for it. + log("app root=" + str(int(root)) + " " + " ".join(args)) + sys.exit("the app answers for itself") elif name == "tailscaled": system = os.path.abspath(sys.argv[0]) == os.environ["MACOS_HOST_DAEMON"] log(("daemon" if system else "tailscaled") + " root=" + str(int(root)) + " " + " ".join(args)) @@ -382,6 +430,7 @@ def need_root(): sys.exit("unexpected tailscaled call: " + " ".join(args)) need_root() write("LaunchDaemons/com.tailscale.tailscaled.plist", "") + write("daemon.exe", os.path.abspath(sys.argv[0])) write("daemon.up") write("daemon.running", read("formula.installed")) if not os.path.exists(path("daemon.link")): @@ -429,12 +478,13 @@ def setUp(self) -> None: self.bin.mkdir(parents=True) self.stub.mkdir() # The stand-ins for the tools every Mac has. tailscaled and tailscale appear in the bin directory when the stand-in brew installs the formula. - for name in ("pmset", "brew", "sudo"): + for name in ("pmset", "brew", "defaults", "sudo"): tool = self.bin / name tool.write_text(STUB.format(python=sys.executable)) tool.chmod(0o755) self.plist = self.stub / "LaunchDaemons" / PLIST_NAME - # Where tailscaled install-system-daemon puts the daemon it installs. + # Where the cask puts the app, and where tailscaled install-system-daemon puts the daemon it installs. + self.app_bundle = self.work / "Applications" / "Tailscale.app" self.daemon_binary = self.stub / "usr-local-bin" / "tailscaled" self.set_power(sleepdisabled="0", sleep="1", displaysleep="10", disksleep="10") @@ -458,11 +508,17 @@ def hand_built_daemon(self) -> None: self.plist.parent.mkdir() self.plist.write_text("") - def formula_daemon(self, installed: str = "1.2.0", latest: str = "1.2.0", running: str | None = None, binary: str | None = None, link: bool = False) -> None: - """A host whose daemon the formula installed: the formula at `installed` while `latest` is out, the daemon binary at `binary` and the running daemon at `running` (each the formula's release unless given), and its login state Running. `link` makes the daemon path a link to the formula, as on a prefix of /usr/local.""" + def formula_daemon(self, installed: str = "1.2.0", latest: str = "1.2.0", running: str | None = None, binary: str | None = None, link: bool = False, wrapper: bool = False) -> None: + """A host whose daemon the formula installed: the formula at `installed` while `latest` is out, the daemon binary at `binary` and the running daemon at `running` (each the formula's release unless given), and its login state Running. `link` makes the daemon path a link to the formula, as on a prefix of /usr/local. `wrapper` puts the standalone app's wrapper script where the formula's tailscale would be linked, so the formula is in its keg and not linked, as Homebrew leaves it there.""" + keg = self.prefix / "opt" / "tailscale" / "bin" + keg.mkdir(parents=True) + if wrapper: + self.app_wrapper() for tool in ("tailscaled", "tailscale"): - (self.bin / tool).write_text(STUB.format(python=sys.executable)) - (self.bin / tool).chmod(0o755) + (keg / tool).write_text(STUB.format(python=sys.executable)) + (keg / tool).chmod(0o755) + if not wrapper: + (self.bin / tool).symlink_to(keg / tool) (self.stub / "formula.installed").write_text(installed) (self.stub / "formula.latest").write_text(latest) (self.stub / "daemon.running").write_text(running or installed) @@ -477,12 +533,34 @@ def formula_daemon(self, installed: str = "1.2.0", latest: str = "1.2.0", runnin self.plist.parent.mkdir(exist_ok=True) self.plist.write_text("") - def app(self, installed: str | None = "1.2.0", latest: str = "1.2.0") -> None: - """The standalone app as the Homebrew cask: installed at `installed` (None for not installed) while `latest` is out.""" + @property + def wrapper(self) -> Path: + return self.bin / "tailscale" + + def app_wrapper(self) -> None: + """The standalone app's wrapper script where the formula's tailscale would be linked, calling the stand-in app binary.""" + app_binary = self.app_bundle / "Contents" / "MacOS" / "Tailscale" + app_binary.parent.mkdir(parents=True) + app_binary.write_text(STUB.format(python=sys.executable)) + app_binary.chmod(0o755) + self.wrapper.write_text(APP_WRAPPER.format(bundle=self.app_bundle)) + self.wrapper.chmod(0o755) + + def assert_wrapper_untouched(self) -> None: + self.assertEqual(self.wrapper.read_text(), APP_WRAPPER.format(bundle=self.app_bundle)) + self.assertEqual([call for call in self.calls() if call.startswith("app ")], [], "the app answers for itself and not for the daemon") + + def app(self, installed: str | None = "1.2.0", latest: str = "1.2.0", bundle: str | None = None) -> None: + """The standalone app as the Homebrew cask: installed at `installed` (None for not installed) while `latest` is out, and the app bundle at `bundle` (the cask's installed release unless given). They differ when the app has updated itself, which Homebrew's record of the cask does not follow.""" if installed is not None: (self.stub / "cask.installed").write_text(installed) + (self.stub / "app.version").write_text(bundle or installed) (self.stub / "cask.latest").write_text(latest) + def pkg_fails(self) -> None: + """The cask's installer fails as the Tailscale pkg's does ("An error occurred while running scripts from the package"), and Homebrew reverts.""" + (self.stub / "cask.pkgfail").write_text("") + def state(self, name: str) -> str: return (self.stub / name).read_text() @@ -493,6 +571,7 @@ def run_role(self, *extra: str, system: str = "Darwin", machine: str = "arm64", "github_runner_macos_host_homebrew_prefix": str(self.prefix), "github_runner_macos_host_tailscale_plist": str(self.plist), "github_runner_macos_host_tailscale_daemon_binary": str(self.daemon_binary), + "github_runner_macos_host_tailscale_app_bundle": str(self.app_bundle), # The install and join tests are about those, and a stand-in formula that is installed would otherwise be upgraded on their second runs: the update has tests of its own, which turn it on. "github_runner_macos_host_tailscale_update": False, **variables, @@ -507,6 +586,7 @@ def run_role(self, *extra: str, system: str = "Darwin", machine: str = "arm64", "ANSIBLE_RETRY_FILES_ENABLED": "false", "MACOS_HOST_STUB": str(self.stub), "MACOS_HOST_DAEMON": str(self.daemon_binary), + "MACOS_HOST_APP": str(self.app_bundle), # The stand-ins come first on the playbook's own PATH as well, so nothing the role runs can reach this machine's real pmset, Homebrew or sudo. "PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}", } @@ -526,7 +606,7 @@ def logged(self) -> list[str]: def writes(self, *tools: str) -> list[str]: """The calls that change the host, without the read-only ones the role makes to find out.""" wanted = tools or ("pmset", "brew", "tailscaled", "tailscale") - read_only = re.compile(r"(pmset root=0 -g( custom)?|brew root=0 (info|list|outdated|update|--version).*|tailscaled root=0 --version|daemon root=0 --version|tailscale root=0 status .*)$") + read_only = re.compile(r"(pmset root=0 -g( custom)?|brew root=0 (info|list|outdated|update|--version).*|defaults root=0 read .*|tailscaled root=0 --version|daemon root=0 --version|tailscale root=0 status .*)$") return [call for call in self.calls() if call.split(" ")[0] in wanted and not read_only.match(call)] def recap(self, result: subprocess.CompletedProcess[str]) -> dict[str, int]: @@ -937,6 +1017,136 @@ def test_the_update_never_logs_in_even_with_the_daemon_waiting_for_one(self) -> self.assertEqual(self.state("login.state"), "NeedsLogin") self.assertFalse([call for call in self.calls() if call.startswith("tailscale ") and " up" in call]) + # A Mac whose /usr/local/bin/tailscale is the standalone app's wrapper script, where Homebrew cannot link the formula. + + def keg_tailscaled(self) -> Path: + return self.prefix / "opt" / "tailscale" / "bin" / "tailscaled" + + def test_a_formula_that_homebrew_cannot_link_beside_the_apps_wrapper_is_upgraded_and_its_daemon_refreshed(self) -> None: + self.formula_daemon(installed="1.1.0", latest="1.2.0", wrapper=True) + result = self.update_role() + self.ok(result) + self.assertEqual(self.writes(), ["brew root=0 upgrade tailscale", "tailscaled root=1 install-system-daemon"]) + self.assertEqual((self.state("formula.installed"), self.state("daemon.version"), self.state("daemon.running")), ("1.2.0", "1.2.0", "1.2.0")) + # The daemon is installed from the keg, since nothing is linked into the prefix, and the wrapper is as it was. + self.assertEqual(self.state("daemon.exe"), str(self.keg_tailscaled())) + self.assertFalse((self.bin / "tailscaled").exists()) + self.assert_wrapper_untouched() + self.assertEqual(self.recap(result)["changed"], 2, result.stdout) + self.assertIn("is not linked into", result.stdout) + + def test_after_an_upgrade_that_could_not_link_a_second_run_and_a_dry_run_change_nothing(self) -> None: + self.formula_daemon(installed="1.1.0", latest="1.2.0", wrapper=True) + self.ok(self.update_role()) + before = self.writes() + for extra in ((), ("--check",)): + with self.subTest(extra=extra): + result = self.update_role(*extra) + self.ok(result) + self.assertEqual(self.writes(), before) + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + + def test_a_dry_run_reports_the_upgrade_that_could_not_link_and_makes_no_change(self) -> None: + self.formula_daemon(installed="1.1.0", latest="1.2.0", wrapper=True) + result = self.update_role("--check") + self.ok(result) + self.assertEqual(self.writes(), []) + self.assertEqual(self.state("formula.installed"), "1.1.0") + self.assertEqual(self.recap(result)["changed"], 2, result.stdout) + + def test_the_running_daemon_is_asked_through_the_formula_and_not_through_the_apps_wrapper(self) -> None: + # The wrapper talks to the app, so a stale tailscaled is only seen by the formula's own tailscale. + self.formula_daemon(installed="1.2.0", latest="1.2.0", running="1.1.0", wrapper=True) + result = self.update_role() + self.ok(result) + self.assertEqual(self.writes(), ["tailscaled root=1 install-system-daemon"]) + self.assertEqual(self.state("daemon.running"), "1.2.0") + self.assert_wrapper_untouched() + + def test_a_first_install_beside_the_apps_wrapper_installs_the_daemon_from_the_keg(self) -> None: + self.app_wrapper() + (self.stub / "formula.latest").write_text("1.2.0") + self.set_power() + result = self.run_role() + self.ok(result) + self.assertEqual(self.writes(), ["brew root=0 install tailscale", "tailscaled root=1 install-system-daemon"]) + self.assertEqual(self.state("daemon.exe"), str(self.keg_tailscaled())) + self.assertEqual(self.daemon_binary.read_text(), self.keg_tailscaled().read_text()) + self.assert_wrapper_untouched() + self.assertEqual(self.recap(result)["failed"], 0, result.stdout) + + def test_the_join_beside_the_apps_wrapper_goes_through_the_formulas_tailscale_to_the_daemon(self) -> None: + self.app_wrapper() + (self.stub / "formula.latest").write_text("1.2.0") + self.set_power() + result = self.run_role(github_runner_macos_host_tailscale_auth_key=AUTH_KEY) + self.ok(result) + self.assertEqual(self.state("login.state"), "Running") + self.assertIn(f"tailscale key={key_hash(AUTH_KEY)} --auth-key=file:/dev/stdin", self.calls()) + self.assert_wrapper_untouched() + + def test_a_formula_that_did_not_install_fails_the_run_with_homebrews_output(self) -> None: + self.formula_daemon(installed="1.1.0", latest="1.2.0", wrapper=True) + (self.stub / "formula.fail").write_text("") + result = self.update_role() + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("no bottle available", result.stdout) + self.assertEqual(self.writes(), ["brew root=0 upgrade tailscale"]) + self.assertEqual(self.state("formula.installed"), "1.1.0") + + def test_a_first_install_that_did_not_install_fails_the_run(self) -> None: + (self.stub / "formula.fail").write_text("") + self.set_power() + result = self.run_role() + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("left the formula not installed", result.stdout) + self.assertEqual(self.writes(), ["brew root=0 install tailscale"]) + + # An app that has updated itself. + + def test_an_app_that_has_updated_itself_is_not_upgraded_although_homebrew_lists_it_as_outdated(self) -> None: + self.formula_daemon() + self.app(installed="1.1.0", latest="1.2.0", bundle="1.2.0") + self.pkg_fails() + for extra in ((), ("--check",)): + with self.subTest(extra=extra): + result = self.update_role(*extra) + self.ok(result) + self.assertTrue([call for call in self.calls() if call.startswith("defaults root=0 read ")]) + self.assertEqual(self.writes(), []) + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + + def test_an_app_ahead_of_the_cask_is_not_downgraded(self) -> None: + self.formula_daemon() + self.app(installed="1.1.0", latest="1.2.0", bundle="1.3.0") + self.pkg_fails() + result = self.update_role() + self.ok(result) + self.assertEqual(self.writes(), []) + + def test_an_app_that_is_behind_the_cask_is_upgraded_although_the_record_says_otherwise(self) -> None: + self.formula_daemon() + self.app(installed="1.2.0", latest="1.3.0", bundle="1.1.0") + result = self.update_role() + self.ok(result) + self.assertEqual(self.writes(), ["brew root=0 upgrade --cask tailscale-app"]) + + def test_an_app_whose_bundle_cannot_be_read_is_put_back_by_the_cask(self) -> None: + self.formula_daemon() + self.app(installed="1.1.0", latest="1.2.0") + (self.stub / "app.version").unlink() + result = self.update_role() + self.ok(result) + self.assertEqual(self.writes(), ["brew root=0 upgrade --cask tailscale-app"]) + + def test_a_cask_installer_that_fails_for_an_app_that_is_behind_fails_the_run(self) -> None: + self.formula_daemon() + self.app(installed="1.1.0", latest="1.2.0") + self.pkg_fails() + result = self.update_role() + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("An error occurred while running scripts from the package", result.stdout) + # Joining a tailnet. def test_an_auth_key_joins_once_with_the_key_on_standard_input_and_nowhere_else(self) -> None: