diff --git a/README.md b/README.md index f363c1dc..62ab222e 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ ansible-playbook -i inventory.yml exadev.github_runner.site --limit node1 --chec ansible-playbook -i inventory.yml exadev.github_runner.site --limit node1 # real run ``` -The playbook runs up to seven roles, each with its own README: `github_runner_host_tools` installs Homebrew and mise on every macOS and Debian-family Linux host first, keeps both at their latest release and puts them in the connecting user's shell, and writes npm and pnpm package-install security settings and shell environment variables from the variables a host gives (it uses `become` and Homebrew's installer, so run the play with passwordless sudo or `--ask-become-pass`, and `bootstrap.sh` turns it off), `github_runner_macos_host` sets up a Mac's host baseline next (AC power settings and `disablesleep` that stop it sleeping, lid closed included, and its own Tailscale client as the `tailscaled` system daemon; it uses `become`, as do the Linux host role and a native node, so run the play with passwordless sudo or `--ask-become-pass`, and `bootstrap.sh` turns it off), `github_runner_linux_host` sets up a Linux host's baseline (keeping it from suspending and its Wi-Fi from power saving; both off unless a host's variables turn them on), `github_runner_cluster` brings the host up as a k3s node, in a container under Docker Compose or, with `github_runner_cluster_node_mode: native`, directly under systemd on a Linux host, `github_runner_arc` installs ARC and the fleet-health platform, `github_runner_k8s_client` (included by the other two) gives a host the kubernetes Python client, and `github_runner_secrets_onepassword` reads the secrets from 1Password when `github_runner_secrets_source` is `onepassword`. The cluster and ARC roles read every secret (`K3S_TOKEN`, the mesh keys, GHCR credentials, the heartbeat PAT, each org's App private key) as a plain variable, so any source Ansible reads works: ansible-vault, SOPS, an env or file lookup, HashiCorp Vault or AWS SSM (see `examples/ansible-vault/`, `examples/env/` and `examples/onepassword/`). With `github_runner_secrets_source: onepassword`, every secret is read live from one 1Password item at task time; `roles/github_runner_secrets_onepassword/README.md` lists the item's fields and the `op://` references. `bootstrap.sh` sets the same variables directly from `.env.bootstrap`. The cluster role ships the node's `docker-compose.yml` and k3s image build context in `roles/github_runner_cluster/files/`, copies them on every run into each host's `github_runner_cluster_dir` (`~/.github-runner` by default), and templates the `.env` for their `k3s`/`k3s-agent` services there, so a host needs no checkout of this repository. The Compose project name is pinned to `github-runner` (`github_runner_cluster_compose_project`) rather than taken from the directory's name, because the named volumes holding the k3s datastore and each node's mesh identity are prefixed with it; the directory can move without losing them. +The playbook runs up to seven roles, each with its own README: `github_runner_host_tools` installs Homebrew and mise on every macOS and Debian-family Linux host first, keeps both at their latest release and puts them in the connecting user's shell, and writes npm and pnpm package-install security settings and shell environment variables from the variables a host gives (it uses `become` and Homebrew's installer, so run the play with passwordless sudo or `--ask-become-pass`, and `bootstrap.sh` turns it off), `github_runner_macos_host` sets up a Mac's host baseline next (AC power settings and `disablesleep` that stop it sleeping, lid closed included, and its own Tailscale client as the `tailscaled` system daemon, which it keeps at the latest release with `github_runner_macos_host_tailscale_update`; it uses `become`, as do the Linux host role and a native node, so run the play with passwordless sudo or `--ask-become-pass`, and `bootstrap.sh` turns it off), `github_runner_linux_host` sets up a Linux host's baseline (keeping it from suspending and its Wi-Fi from power saving, both off unless a host's variables turn them on, and an installed Tailscale package current with `github_runner_linux_host_tailscale_update`, on by default), `github_runner_cluster` brings the host up as a k3s node, in a container under Docker Compose or, with `github_runner_cluster_node_mode: native`, directly under systemd on a Linux host, `github_runner_arc` installs ARC and the fleet-health platform, `github_runner_k8s_client` (included by the other two) gives a host the kubernetes Python client, and `github_runner_secrets_onepassword` reads the secrets from 1Password when `github_runner_secrets_source` is `onepassword`. The cluster and ARC roles read every secret (`K3S_TOKEN`, the mesh keys, GHCR credentials, the heartbeat PAT, each org's App private key) as a plain variable, so any source Ansible reads works: ansible-vault, SOPS, an env or file lookup, HashiCorp Vault or AWS SSM (see `examples/ansible-vault/`, `examples/env/` and `examples/onepassword/`). With `github_runner_secrets_source: onepassword`, every secret is read live from one 1Password item at task time; `roles/github_runner_secrets_onepassword/README.md` lists the item's fields and the `op://` references. `bootstrap.sh` sets the same variables directly from `.env.bootstrap`. The cluster role ships the node's `docker-compose.yml` and k3s image build context in `roles/github_runner_cluster/files/`, copies them on every run into each host's `github_runner_cluster_dir` (`~/.github-runner` by default), and templates the `.env` for their `k3s`/`k3s-agent` services there, so a host needs no checkout of this repository. The Compose project name is pinned to `github-runner` (`github_runner_cluster_compose_project`) rather than taken from the directory's name, because the named volumes holding the k3s datastore and each node's mesh identity are prefixed with it; the directory can move without losing them. Which hosts are k3s servers is worked out from the inventory, not set per host. The `github_runner_cluster` inventory group lists the cluster's hosts in order: the first N become servers (control-plane/etcd members running `k3s server`, genuine voting members of the raft quorum), where N is the largest odd number no greater than both the number of hosts and `github_runner_cluster_max_servers` (5 by default), so 1 or 2 hosts give 1 server, 3 or 4 give 3, and 5 or more give 5. The first server bootstraps the cluster (`--cluster-init`, embedded etcd) and every other host joins it, the rest as agents (`k3s agent`, schedulable capacity with no control-plane role). Join URLs and every server's `--tls-san` list are derived from each server's node name and `github_runner_cluster_tailnet_domain`. A host can still set `github_runner_cluster_node_role`, `github_runner_cluster_bootstrap`, `github_runner_cluster_server_url` or `github_runner_cluster_tls_sans` to override its part, and the run fails with an explanation if the result has an even number of servers, none at all, or more than one bootstrap host; `roles/github_runner_cluster/defaults/main.yml` describes each. Installing the ARC controller, each org's runner scale set, and the fleet-health platform is not tied to a node role at all: each is driven purely by whether the installing host's own `github_runner_arc_orgs`/`github_runner_arc_heartbeat_gist_id` is set, so any server host can be the one Ansible installs against, and moving which host does the installing is a config change (which host's `host_vars` sets it), not a role change. See the [Architecture](#architecture) section for why 3 servers is the minimum sane count for genuine HA (2 is worse than 1 for quorum math, not better). To add a server or agent, add the host to the `github_runner_cluster` group in the fleet's inventory and give it its own `host_vars/.yml`; keep the bootstrap host first. diff --git a/roles/github_runner_linux_host/README.md b/roles/github_runner_linux_host/README.md index 7cbe12ed..3167ed06 100644 --- a/roles/github_runner_linux_host/README.md +++ b/roles/github_runner_linux_host/README.md @@ -1,6 +1,6 @@ # github_runner_linux_host -The host-level baseline of a Linux host that carries a k3s node, for a machine that is dedicated to the cluster. It does nothing on any other operating system, so it can be applied to a mixed inventory, and every step is off by default and behind a variable of its own, because each changes how the host behaves for whoever else uses it. `playbooks/site.yml` runs it before the fleet play, on every host of the cluster group; a fleet turns the steps on per host in `host_vars`. It touches neither the node nor the host's Tailscale client, which `github_runner_cluster` owns (see its [Native nodes](../github_runner_cluster/README.md#native-nodes)). +The host-level baseline of a Linux host that carries a k3s node, for a machine that is dedicated to the cluster. It does nothing on any other operating system, so it can be applied to a mixed inventory, and every step but the Tailscale update is off by default and behind a variable of its own, because each changes how the host behaves for whoever else uses it. `playbooks/site.yml` runs it before the fleet play, on every host of the cluster group; a fleet turns the steps on per host in `host_vars`. It touches neither the node nor the host's Tailscale client, which `github_runner_cluster` owns (see its [Native nodes](../github_runner_cluster/README.md#native-nodes)). Its tasks that change the host use `become` on their own, so the connecting user needs passwordless sudo, or the play needs `--ask-become-pass`. Everything the role only reads runs as the connecting user, and under `--check` nothing is changed and each change a real run would make is reported. Every variable is checked against `meta/argument_specs.yml` when the role runs. @@ -51,6 +51,12 @@ The display manager is stopped before it is disabled, so a name that is an alias The reboot is guarded. A host on which any unit of `github_runner_linux_host_node_services` (`k3s-agent.service` and `k3s.service` by default; a Docker node would list `docker.service`) is active is a running node of the cluster, and rebooting it takes the node off the cluster and fails the jobs its runner pods are running. The role refuses: it fails, after the packages are upgraded and before any reboot or autoremove, naming the active unit. Draining the node and stopping its service is the operator's, and a run with `github_runner_linux_host_upgrade_reboot_running_node: true` reboots it anyway. `github_runner_linux_host_upgrade_reboot: false` installs the upgrade, says a reboot is due, and reboots nothing. A release upgrade (`do-release-upgrade`) is not part of this. +## Keeping Tailscale current + +`github_runner_linux_host_tailscale_update` (default `true`) is the one step that is on by default, because it installs nothing and changes only what the host already runs. When the package `tailscale` is installed and apt has a newer release of it, the role upgrades that package alone (`apt-get install --only-upgrade tailscale`, never a blanket upgrade; the index is refreshed first with `apt-get update`), and it enables and starts the `tailscaled` service if either is missing (`systemctl enable --now tailscaled`). Tailscale's own package scripts restart a running `tailscaled` on upgrade and keep its login, and its apt repository is where newer releases come from ([Install Tailscale on Linux](https://tailscale.com/kb/1031/install-linux)); the restart drops the host\'s tailnet connections, an SSH session over it included, for a moment. The role never installs the package, adds the repository, joins a tailnet or logs in. + +A host that is not Debian or Ubuntu, a host without the package, and a package no repository carries a newer release of are skipped, with a message and without failing the run. A native node of the cluster (`github_runner_cluster_node_mode: native` while `github_runner_cluster_native_tailscale_install` is true, its default) is skipped too: the cluster role installs the release it pins there, `github_runner_cluster_tailscale_version`, and puts a host back to it on every run, so a newer release put here would be replaced on the same run. Under `--check` each change is reported and none is made. + ## Variables - `github_runner_linux_host_keep_awake` (default `false`): mask the sleep targets and have logind ignore the lid switch. @@ -65,7 +71,8 @@ The reboot is guarded. A host on which any unit of `github_runner_linux_host_nod - `github_runner_linux_host_upgrade_reboot` (default `true`), `github_runner_linux_host_upgrade_reboot_running_node` (default `false`), `github_runner_linux_host_node_services` and `github_runner_linux_host_reboot_timeout` (default 600): whether the upgrade reboots, whether it may reboot a running node, which units make a host a running node, and how long it waits for the host to come back. - `github_runner_linux_host_sleep_targets`, `github_runner_linux_host_lid_switch_settings`, `github_runner_linux_host_kernel_modules`, `github_runner_linux_host_sysctl_settings` and `github_runner_linux_host_default_target`: the targets masked, the logind settings set to `ignore`, the modules loaded, the sysctls set and the target booted to. Constants, not settings; variables only so the tests can narrow them. - `github_runner_linux_host_logind_dropin`, `github_runner_linux_host_networkmanager_dropin`, `github_runner_linux_host_modules_load_file`, `github_runner_linux_host_sysctl_file`, `github_runner_linux_host_sys_module_dir`, `github_runner_linux_host_power_supply_dir`, `github_runner_linux_host_battery_unit`, `github_runner_linux_host_unattended_upgrades_file`, `github_runner_linux_host_reboot_required_file`: where the role's files go and what it reads and runs. Not settings; variables only so the tests can point them at a stand-in. +- `github_runner_linux_host_tailscale_update` (default `true`): keep the installed Tailscale package current and its service running, as under Keeping Tailscale current above. ## Tests -`tests/unit/test_linux_host.py` runs the role (`tests/linux_host/run.yml`) against stand-ins for `systemctl`, `nmcli`, `iw`, `modprobe`, `sysctl`, `apt-get`, `dpkg-query`, `uname` and `shutdown` on `PATH`, with every file the role writes or reads redirected and the host's facts stood in for. It checks for each step that it is off by default, does what it says, changes nothing on a second run, and under `--check` reports each change and makes none; and in particular the order of the modules and the sysctls, a battery without the attribute, a limit outside 1 to 100, a display manager worked out from the alias, from its own unit when the alias is not loaded and from a unit the host names, a host with none, services the host does not have, the apt steps refused on a host without apt, the order of the upgrade, a running node's reboot refused with the packages already upgraded, and the defaults, argument specs and this README agreeing. +`tests/unit/test_linux_host.py` runs the role (`tests/linux_host/run.yml`) against stand-ins for `systemctl`, `nmcli`, `iw`, `modprobe`, `sysctl`, `apt-get`, `dpkg-query`, `uname` and `shutdown` on `PATH`, with every file the role writes or reads redirected and the host's facts stood in for. It checks for each step but the Tailscale update that it is off by default (and that the update is on, and leaves the host alone without the package), does what it says, changes nothing on a second run, and under `--check` reports each change and makes none; and in particular the order of the modules and the sysctls, a battery without the attribute, a limit outside 1 to 100, a display manager worked out from the alias, from its own unit when the alias is not loaded and from a unit the host names, a host with none, services the host does not have, the apt steps refused on a host without apt, the order of the upgrade, a running node's reboot refused with the packages already upgraded, and the defaults, argument specs and this README agreeing. diff --git a/roles/github_runner_linux_host/defaults/main.yml b/roles/github_runner_linux_host/defaults/main.yml index 00cfa346..6e2c5fad 100644 --- a/roles/github_runner_linux_host/defaults/main.yml +++ b/roles/github_runner_linux_host/defaults/main.yml @@ -1,5 +1,5 @@ --- -# The host-level baseline of a Linux host that carries a k3s node: nothing here touches the node itself, or the Tailscale client. Every variable is checked against meta/argument_specs.yml when the role runs. Every step is off by default, each behind a variable of its own, because each changes how the host behaves for whoever else uses it: a fleet turns them on per host, for a machine that is dedicated to the cluster. +# The host-level baseline of a Linux host that carries a k3s node: nothing here touches the node itself, or the Tailscale client. Every variable is checked against meta/argument_specs.yml when the role runs. Every step but the last, the Tailscale update, is off by default, each behind a variable of its own, because each changes how the host behaves for whoever else uses it: a fleet turns them on per host, for a machine that is dedicated to the cluster. # Whether the host is kept from suspending: sleep.target, suspend.target, hibernate.target and hybrid-sleep.target are masked, so nothing can put it to sleep (a desktop's idle timer, a power button, `systemctl suspend`), and systemd-logind is told to ignore the lid switch, which a laptop would otherwise suspend on whenever its lid closes, with or without an external monitor or power. A host that sleeps drops off the network, so its node leaves the cluster and its runner pods stop. github_runner_linux_host_keep_awake: false @@ -92,3 +92,6 @@ github_runner_linux_host_upgrade_reboot_running_node: false github_runner_linux_host_reboot_timeout: 600 # Not a setting: the file Debian and Ubuntu create when an upgrade needs a reboot. A variable only so the tests can point it at a stand-in. github_runner_linux_host_reboot_required_file: /var/run/reboot-required + +# Whether the host's Tailscale client is kept current, the one step that is on by default because it installs nothing and changes only what the host already runs: when the package tailscale is installed (from Tailscale's apt repository, https://tailscale.com/kb/1031/install-linux) and apt has a newer release of it, only that package is upgraded (apt-get install --only-upgrade tailscale, never a blanket upgrade), and the tailscaled service is enabled and started if it is not. A host without the package, and one that is not Debian or Ubuntu, is skipped with a message, and the role never installs the package, joins a tailnet or logs in. A native node of the cluster is skipped too, because the cluster role installs the release it pins there (github_runner_cluster_tailscale_version) and would put it back on the same run. The upgrade restarts a running tailscaled, which keeps its login and drops the host's tailnet connections for a moment. Under --check each change is reported and none is made. +github_runner_linux_host_tailscale_update: true diff --git a/roles/github_runner_linux_host/meta/argument_specs.yml b/roles/github_runner_linux_host/meta/argument_specs.yml index bd4f0647..58c20fef 100644 --- a/roles/github_runner_linux_host/meta/argument_specs.yml +++ b/roles/github_runner_linux_host/meta/argument_specs.yml @@ -4,7 +4,7 @@ argument_specs: main: short_description: 'Prepares a Linux host that carries a k3s node: keeps it awake, headless and up to date, and ready for k3s.' description: - - Runs on Linux hosts only and does nothing on any other. Every step is off by default and behind a variable of its own. Its tasks that change the host use become (sudo), so run the play with passwordless sudo for the connecting user or with --ask-become-pass. + - Runs on Linux hosts only and does nothing on any other. Every step but the Tailscale update is off by default, and each is behind a variable of its own. Its tasks that change the host use become (sudo), so run the play with passwordless sudo for the connecting user or with --ask-become-pass. options: github_runner_linux_host_keep_awake: type: bool @@ -161,3 +161,7 @@ argument_specs: type: path default: /var/run/reboot-required description: The file that says an upgrade needs a reboot. Not a setting, and a variable only so the tests can point it at a stand-in. + github_runner_linux_host_tailscale_update: + type: bool + default: true + description: "Whether the host's Tailscale client is kept current, the one step that is on by default: when the package tailscale is installed and apt has a newer release of it, only that package is upgraded (apt-get install --only-upgrade tailscale), and the tailscaled service is enabled and started if it is not. Nothing is installed and no tailnet is joined. A host without the package, a host that is not Debian or Ubuntu, and a native node of the cluster (whose cluster role installs the release it pins) are skipped with a message." diff --git a/roles/github_runner_linux_host/tasks/main.yml b/roles/github_runner_linux_host/tasks/main.yml index 08d24a93..c356f195 100644 --- a/roles/github_runner_linux_host/tasks/main.yml +++ b/roles/github_runner_linux_host/tasks/main.yml @@ -1,5 +1,5 @@ --- -# Linux hosts only. Every step is off by default, each behind a variable of its own, and every one is skipped on any other host, so the role can be applied to a mixed inventory. The tasks that change the host use become (sudo); everything the role only reads runs as the connecting user. +# Linux hosts only. Every step but the last, the Tailscale update, is off by default, each behind a variable of its own, and every one is skipped on any other host, so the role can be applied to a mixed inventory. The tasks that change the host use become (sudo); everything the role only reads runs as the connecting user. - name: Read which operating system the host has # Only the platform subset, the cheapest that holds it, and only when the caller did not gather it: a play with gather_facts: false leaves ansible_facts without it. Read-only, so it runs under --check too. @@ -58,3 +58,9 @@ when: - ansible_facts['system'] == 'Linux' - github_runner_linux_host_upgrade | bool + +- name: Keep the host's Tailscale client current + ansible.builtin.include_tasks: tailscale_update.yml + when: + - ansible_facts['system'] == 'Linux' + - github_runner_linux_host_tailscale_update | bool diff --git a/roles/github_runner_linux_host/tasks/tailscale_update.yml b/roles/github_runner_linux_host/tasks/tailscale_update.yml new file mode 100644 index 00000000..58b23e94 --- /dev/null +++ b/roles/github_runner_linux_host/tasks/tailscale_update.yml @@ -0,0 +1,125 @@ +--- +# Keeps the Tailscale client the host already has current, and installs nothing: when the package tailscale is installed and apt has a newer release of it, only that package is upgraded (apt-get install --only-upgrade tailscale, never a blanket upgrade), and the tailscaled service is enabled and running. Tailscale's own packages enable and start the service and restart a running one on upgrade, keeping its login (https://tailscale.com/kb/1031/install-linux), and the vendor's apt repository is the one that carries newer releases. Debian and Ubuntu only, since the package is apt's; any other host, and a host without the package, is skipped with a message and the run does not fail. This never joins or logs in to a tailnet. Under --check each change is reported and none is made. + +- name: Say that a host without apt is skipped + ansible.builtin.debug: + msg: "Tailscale is not updated on {{ inventory_hostname }}: it is a {{ ansible_facts['os_family'] }} host, and the package is apt's." + when: ansible_facts['os_family'] != 'Debian' + +- name: Keep the Tailscale package current + when: ansible_facts['os_family'] == 'Debian' + block: + - name: Read whether the Tailscale package is installed + ansible.builtin.command: + argv: [dpkg-query, "--showformat=${db:Status-Abbrev}", --show, tailscale] + register: github_runner_linux_host_tailscale_package + changed_when: false + failed_when: false + check_mode: false + + - name: Work out whether the cluster role holds the package at a release of its own + # A native node's cluster role installs the pinned release and brings a host back to it with --allow-downgrades on every run, so a newer release put here would be replaced by the pin on the same run, and the client restarted both times. Both variables are the cluster role's: the first is read from the host's variables, and the second is that role's default when the host does not set it. + ansible.builtin.set_fact: + github_runner_linux_host_tailscale_pinned: "{{ (github_runner_cluster_node_mode | default('compose')) == 'native' and (github_runner_cluster_native_tailscale_install | default(true) | bool) }}" + + - name: Say that there is no Tailscale package to keep current + ansible.builtin.debug: + msg: "Tailscale is not updated on {{ inventory_hostname }}: the package tailscale is not installed, and this never installs it." + when: github_runner_linux_host_tailscale_package.stdout | trim != 'ii' + + - name: Say that the cluster role holds the package + ansible.builtin.debug: + msg: >- + Tailscale is not updated on {{ inventory_hostname }}: it is a native node of the cluster, whose cluster role installs the release it + pins (github_runner_cluster_tailscale_version), so the pin decides the release. + when: + - github_runner_linux_host_tailscale_package.stdout | trim == 'ii' + - github_runner_linux_host_tailscale_pinned | bool + + - name: Keep the installed package current + when: + - github_runner_linux_host_tailscale_package.stdout | trim == 'ii' + - not github_runner_linux_host_tailscale_pinned | bool + block: + - name: Refresh the package index + # So that the newest release the repository carries is the candidate. Not a change of the host worth reporting, and not run under --check. + become: true + ansible.builtin.command: + argv: [apt-get, update] + environment: + DEBIAN_FRONTEND: noninteractive + changed_when: false + when: not ansible_check_mode + + - name: Read the installed and the newest release of the package + ansible.builtin.command: + argv: [apt-cache, policy, tailscale] + register: github_runner_linux_host_tailscale_policy + changed_when: false + check_mode: false + + - name: Read the installed and the newest release out of that + ansible.builtin.set_fact: + github_runner_linux_host_tailscale_installed: "{{ github_runner_linux_host_tailscale_policy.stdout | regex_search('Installed: (\\S+)', '\\1') | first | default('(none)') }}" + github_runner_linux_host_tailscale_candidate: "{{ github_runner_linux_host_tailscale_policy.stdout | regex_search('Candidate: (\\S+)', '\\1') | first | default('(none)') }}" + + - name: Work out whether the newest release is newer than the installed one + # (none) is what apt-cache policy prints as the candidate when no repository carries the package. dpkg orders the versions, since a candidate older than the installed release (a package installed from a file) is not an upgrade. + ansible.builtin.command: + argv: [dpkg, --compare-versions, "{{ github_runner_linux_host_tailscale_candidate }}", gt, "{{ github_runner_linux_host_tailscale_installed }}"] + register: github_runner_linux_host_tailscale_newer + changed_when: false + failed_when: github_runner_linux_host_tailscale_newer.rc not in [0, 1] + check_mode: false + when: github_runner_linux_host_tailscale_candidate != '(none)' + + - name: Work out the upgrade + ansible.builtin.set_fact: + github_runner_linux_host_tailscale_upgrade_needed: "{{ github_runner_linux_host_tailscale_newer is not skipped and github_runner_linux_host_tailscale_newer.rc == 0 }}" + + - name: Upgrade the Tailscale package + # Only the one package is named, and --only-upgrade never installs it. The package's scripts restart a running tailscaled, which keeps its login. + become: true + ansible.builtin.command: + argv: [apt-get, install, --yes, --no-install-recommends, --only-upgrade, tailscale] + environment: + DEBIAN_FRONTEND: noninteractive + changed_when: true + when: + - github_runner_linux_host_tailscale_upgrade_needed | bool + - not ansible_check_mode + + - name: Report the package upgrade a real run would make + ansible.builtin.debug: + msg: "apt-get install --only-upgrade tailscale, from {{ github_runner_linux_host_tailscale_installed }} to {{ github_runner_linux_host_tailscale_candidate }}" + changed_when: true + when: + - github_runner_linux_host_tailscale_upgrade_needed | bool + - ansible_check_mode + + - name: Read whether the Tailscale service is enabled and running + # One read each; either missing needs the service enabled and started. + ansible.builtin.command: + argv: [systemctl, "{{ item }}", tailscaled] + loop: [is-enabled, is-active] + register: github_runner_linux_host_tailscaled_state + changed_when: false + failed_when: false + check_mode: false + + - name: Enable and start the Tailscale service + become: true + ansible.builtin.command: + argv: [systemctl, enable, --now, tailscaled] + changed_when: true + when: + - github_runner_linux_host_tailscaled_state.results | map(attribute='rc') | select('ne', 0) | list | length > 0 + - not ansible_check_mode + + - name: Report the service change a real run would make + ansible.builtin.debug: + msg: "systemctl enable --now tailscaled" + changed_when: true + when: + - github_runner_linux_host_tailscaled_state.results | map(attribute='rc') | select('ne', 0) | list | length > 0 + - ansible_check_mode diff --git a/roles/github_runner_macos_host/README.md b/roles/github_runner_macos_host/README.md index e52caf0e..537c228d 100644 --- a/roles/github_runner_macos_host/README.md +++ b/roles/github_runner_macos_host/README.md @@ -33,12 +33,18 @@ 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. A host that already has that file is left entirely alone, whether this role or a person installed it: nothing is installed, replaced or restarted. +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. 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. 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. + +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. + ## Variables - `github_runner_macos_host_sudoers_nopasswd` (default false), `github_runner_macos_host_sudoers_user` (default empty, the connecting user): see Passwordless sudo above. @@ -50,6 +56,8 @@ Without the key, join the host by hand once: `sudo tailscale up`, or run the rol - `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_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_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. @@ -71,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, 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), the standalone app is upgraded only where it is installed, 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 0d80f78a..afebf696 100644 --- a/roles/github_runner_macos_host/defaults/main.yml +++ b/roles/github_runner_macos_host/defaults/main.yml @@ -34,6 +34,11 @@ github_runner_macos_host_tailscale: true 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. +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 # 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 2f811ac0..78be1ff5 100644 --- a/roles/github_runner_macos_host/meta/argument_specs.yml +++ b/roles/github_runner_macos_host/meta/argument_specs.yml @@ -70,6 +70,18 @@ argument_specs: type: path default: /Library/LaunchDaemons/com.tailscale.tailscaled.plist description: The launchd definition tailscaled install-system-daemon writes. Not a setting, and a variable only so the tests can point it at a stand-in. + 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." + github_runner_macos_host_tailscale_daemon_binary: + type: path + default: /usr/local/bin/tailscaled + description: "The daemon tailscaled install-system-daemon installs and the launchd definition starts. Not a setting, and a variable only so the tests can point it at a stand-in." + github_runner_macos_host_tailscale_app_cask: + 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_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 edb3e179..00b63a0a 100644 --- a/roles/github_runner_macos_host/tasks/tailscale.yml +++ b/roles/github_runner_macos_host/tasks/tailscale.yml @@ -40,6 +40,11 @@ when: ansible_check_mode changed_when: true +# Keeping what the host has current, after the install above and before any join: replacing the daemon restarts it, and the login read below waits for it to settle. +- name: Keep the host's Tailscale software current + ansible.builtin.include_tasks: tailscale_update.yml + when: github_runner_macos_host_tailscale_update | bool + # 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 ansible.builtin.stat: diff --git a/roles/github_runner_macos_host/tasks/tailscale_update.yml b/roles/github_runner_macos_host/tasks/tailscale_update.yml new file mode 100644 index 00000000..d3e5afd9 --- /dev/null +++ b/roles/github_runner_macos_host/tasks/tailscale_update.yml @@ -0,0 +1,140 @@ +--- +# Keeps the Tailscale software the host already has current, and installs nothing: the Homebrew formula (the formula and no other), the system daemon that formula installed, and the standalone app when it is installed as the Homebrew cask. The daemon is not the formula: tailscaled install-system-daemon copies the formula's tailscaled to the daemon path (or, where that path is already a link to the formula, starts it once), and launchd keeps running whatever was started, so upgrading the formula alone leaves the old release running. tailscaled install-system-daemon removes the installed daemon and its launchd definition first and then installs and starts the new ones (cmd/tailscaled/install_darwin.go in https://github.com/tailscale/tailscale), so running it again is the refresh, and the login state lives in the daemon's own state directory, which it leaves alone. Under --check nothing that changes the host runs: Homebrew's index is not refreshed, so the answer is the one the index it already has gives, and the refresh is reported. This never joins or logs in to a tailnet. + +- name: Look for Homebrew + # A host with the daemon and no Homebrew built it by hand. + ansible.builtin.stat: + path: "{{ github_runner_macos_host_effective_prefix }}/bin/brew" + get_checksum: false + register: github_runner_macos_host_tailscale_brew + +- name: Look for the formula's tailscaled + ansible.builtin.stat: + path: "{{ github_runner_macos_host_effective_prefix }}/bin/tailscaled" + get_checksum: false + register: github_runner_macos_host_tailscale_formula + +- name: Read whether the standalone app is installed as the Homebrew cask + # brew list exits non-zero for a cask that is not installed. Only read here: the cask module below would install an absent cask. + ansible.builtin.command: + argv: ["{{ github_runner_macos_host_effective_prefix }}/bin/brew", list, --cask, "{{ github_runner_macos_host_tailscale_app_cask }}"] + environment: + PATH: "{{ github_runner_macos_host_path }}" + register: github_runner_macos_host_tailscale_app + changed_when: false + failed_when: false + check_mode: false + when: github_runner_macos_host_tailscale_brew.stat.exists + +- name: Work out what there is to keep current + ansible.builtin.set_fact: + # The formula's daemon: the definition existed before this run (otherwise the install above just put the latest release there), and the formula is installed. A daemon built by hand has no formula and is never replaced by the formula's. + github_runner_macos_host_tailscale_formula_managed: >- + {{ github_runner_macos_host_tailscale_brew.stat.exists + and github_runner_macos_host_tailscale_formula.stat.exists + and github_runner_macos_host_tailscale_daemon.stat.exists }} + github_runner_macos_host_tailscale_app_installed: >- + {{ github_runner_macos_host_tailscale_brew.stat.exists + and github_runner_macos_host_tailscale_app.rc == 0 }} + +- name: Say that there is nothing to keep current + ansible.builtin.debug: + msg: >- + Tailscale is not updated on {{ inventory_hostname }}: it has neither the Homebrew formula with its system daemon nor the standalone + app installed as the Homebrew cask {{ github_runner_macos_host_tailscale_app_cask }}. + when: + - github_runner_macos_host_tailscale_daemon.stat.exists + - not github_runner_macos_host_tailscale_formula_managed | bool + - not github_runner_macos_host_tailscale_app_installed | bool + +- name: Refresh Homebrew's index + # So that latest means the current release. Not a change of the host worth reporting, and not run under --check. + ansible.builtin.command: + argv: ["{{ github_runner_macos_host_effective_prefix }}/bin/brew", update, --quiet] + environment: + PATH: "{{ github_runner_macos_host_path }}" + changed_when: false + when: + - github_runner_macos_host_tailscale_formula_managed | bool or github_runner_macos_host_tailscale_app_installed | bool + - not ansible_check_mode + +- name: Keep the formula's daemon current + 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 + + - 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] + register: github_runner_macos_host_tailscale_formula_version + changed_when: false + check_mode: false + + - name: Read the installed daemon's release + # A daemon binary that is missing reads as no release, which differs from the formula's, so the refresh puts it back. + ansible.builtin.command: + argv: ["{{ github_runner_macos_host_tailscale_daemon_binary }}", --version] + register: github_runner_macos_host_tailscale_daemon_version + changed_when: false + failed_when: false + 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. + ansible.builtin.command: + argv: ["{{ github_runner_macos_host_effective_prefix }}/bin/tailscale", status, --json, --peers=false] + register: github_runner_macos_host_tailscale_running + changed_when: false + failed_when: false + check_mode: false + + - 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 + 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)) }} + vars: + # The status JSON's Version is the long form, such as 1.2.3-tabcdef-gabcdef: the release is what precedes the first hyphen. + github_runner_macos_host_tailscale_running_version: >- + {{ ((github_runner_macos_host_tailscale_running.stdout | from_json).Version | default('') | string).split('-')[0] + if github_runner_macos_host_tailscale_running.rc == 0 else '' }} + + - name: Replace the tailscaled system daemon with the formula's release + # 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] + changed_when: true + when: + - github_runner_macos_host_tailscale_refresh_needed | bool + - not ansible_check_mode + + - name: Report the daemon a real run would replace + ansible.builtin.debug: + msg: "tailscaled install-system-daemon, to run the formula's release {{ github_runner_macos_host_tailscale_formula_version.stdout_lines | first | trim }} (installed {{ github_runner_macos_host_tailscale_daemon_version.stdout_lines | first | default('none', true) | trim }})" + changed_when: true + when: + - 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 }}" + when: github_runner_macos_host_tailscale_app_installed | bool diff --git a/tests/unit/test_linux_host.py b/tests/unit/test_linux_host.py index 412add84..fbf1fc65 100644 --- a/tests/unit/test_linux_host.py +++ b/tests/unit/test_linux_host.py @@ -33,6 +33,7 @@ "github_runner_linux_host_unattended_upgrades", "github_runner_linux_host_upgrade", ] +TAILSCALE = "github_runner_linux_host_tailscale_update" SYSCTLS = {"net.ipv4.ip_forward": "1", "net.bridge.bridge-nf-call-iptables": "1", "net.bridge.bridge-nf-call-ip6tables": "1"} @@ -43,7 +44,19 @@ def test_every_step_is_off_by_default_and_has_a_variable_of_its_own(self) -> Non self.assertIs(DEFAULTS[step], False) tasks = yaml.safe_load((ROLE / "tasks" / "main.yml").read_text()) gated = [next(condition for condition in task["when"] if "| bool" in condition).split(" ")[0] for task in tasks if "when" in task and isinstance(task["when"], list)] - self.assertEqual(gated, STEPS) + self.assertEqual(gated, STEPS + [TAILSCALE]) + + def test_the_tailscale_update_is_the_one_step_that_is_on_by_default(self) -> None: + self.assertIs(DEFAULTS[TAILSCALE], True) + + def test_the_tailscale_update_leaves_a_native_node_to_the_cluster_role_under_the_cluster_roles_own_defaults(self) -> None: + # The role reads the cluster role's two variables without loading its defaults, and falls back to these values when the host sets neither. + cluster = yaml.safe_load((ROOT / "roles" / "github_runner_cluster" / "defaults" / "main.yml").read_text()) + self.assertEqual(cluster["github_runner_cluster_node_mode"], "compose") + self.assertIs(cluster["github_runner_cluster_native_tailscale_install"], True) + tasks = (ROLE / "tasks" / "tailscale_update.yml").read_text() + self.assertIn("github_runner_cluster_node_mode | default('compose')", tasks) + self.assertIn("github_runner_cluster_native_tailscale_install | default(true)", tasks) def test_the_k3s_prerequisites_are_the_modules_and_sysctls_k3s_needs(self) -> None: self.assertEqual(DEFAULTS["github_runner_linux_host_kernel_modules"], ["overlay", "br_netfilter"]) @@ -139,7 +152,7 @@ def make_stand_ins(self) -> None: get-default) if [ -e "$state/default-target" ]; then cat "$state/default-target"; else echo graphical.target; fi ;; set-default) printf '%s\n' "$2" > "$state/default-target" ;; mask) touch "$state/masked-$2" ;; - enable) printf '%s\n' enabled > "$state/enabled-$2" ;; + enable) for name; do :; done; printf '%s\n' enabled > "$state/enabled-$name"; case " $* " in *" --now "*) touch "$state/active-$name" ;; esac ;; disable) if [ -e "$state/alias-$2" ]; then printf '%s\n' disabled > "$state/enabled-$(cat "$state/alias-$2")"; rm -f "$state/alias-$2" else printf '%s\n' disabled > "$state/enabled-$2"; for link in "$state"/alias-*; do [ -e "$link" ] && [ "$(cat "$link")" = "$2" ] && rm -f "$link"; done; true; fi ;; @@ -183,6 +196,8 @@ def make_stand_ins(self) -> None: simulate=no; for argument in "$@"; do [ "$argument" = --simulate ] && simulate=yes; done case " $* " in *" install "*unattended-upgrades*) [ "$simulate" = yes ] || touch "$state/package-unattended-upgrades" ;; + *" install "*"--only-upgrade tailscale "*) + if [ "$simulate" = no ] && [ -e "$state/tailscale-candidate" ]; then cp "$state/tailscale-candidate" "$state/tailscale-installed"; fi ;; *" dist-upgrade"*) if [ -e "$state/upgradable" ]; then echo "3 upgraded, 1 newly installed, 0 to remove and 0 not upgraded." @@ -197,10 +212,25 @@ def make_stand_ins(self) -> None: if [ "$simulate" = no ]; then rm -f "$state/removable"; fi else echo "0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded."; fi ;; esac +""") + stand_in(bin_dir / "apt-cache", log + r"""installed=$(cat "$STUB/state/tailscale-installed" 2>/dev/null || echo "(none)") +candidate=$(cat "$STUB/state/tailscale-candidate" 2>/dev/null || echo "(none)") +printf 'tailscale:\n Installed: %s\n Candidate: %s\n Version table:\n' "$installed" "$candidate" +""") + # Only the one comparison the role makes: whether the first version is newer than the second. + stand_in(bin_dir / "dpkg", log + r"""[ "$1" = --compare-versions ] && [ "$3" = gt ] || exit 2 +# Like dpkg, refuse a version that is not one: apt-cache policy prints (none) for a package that is not installed. +case "$2$4" in *"("*) echo "dpkg: error: version '(none)' has bad syntax" >&2; exit 2 ;; esac +[ "$2" != "$4" ] && [ "$(printf '%s\n%s\n' "$2" "$4" | sort -V | tail -n1)" = "$2" ] """) stand_in(bin_dir / "dpkg-query", r"""case "$*" in + *tailscale*) if [ -e "$STUB/state/tailscale-installed" ]; then printf 'ii '; else exit 1; fi ;; *unattended-upgrades*) if [ -e "$STUB/state/package-unattended-upgrades" ]; then printf 'ii '; else exit 1; fi ;; esac +""") + # Stands in for the sudo that become runs, so that a test can see which tasks asked for privilege: it logs, then runs the command it was given as the connecting user. + stand_in(bin_dir / "sudo", log + r"""while [ "$1" != /bin/sh ]; do shift; done +exec "$@" """) stand_in(bin_dir / "uname", r"""echo 6.14.0-test """) @@ -208,7 +238,7 @@ def make_stand_ins(self) -> None: def run_role(self, *args: str, check: bool = False, system: str = "Linux", os_family: str = "Debian", **variables: Any) -> subprocess.CompletedProcess[str]: inventory = self.tmp / "inventory.yml" - inventory.write_text(yaml.safe_dump({"all": {"hosts": {"host": { + host = { "ansible_connection": "local", "ansible_become": False, "github_runner_linux_host_logind_dropin": str(self.logind), @@ -221,8 +251,12 @@ def run_role(self, *args: str, check: bool = False, system: str = "Linux", os_fa "github_runner_linux_host_unattended_upgrades_file": str(self.apt_file), "github_runner_linux_host_reboot_required_file": str(self.reboot_required), "github_runner_linux_host_reboot_timeout": 5, + # The other steps are about their own, and an installed Tailscale package would be upgraded in them: the update has tests of its own, which turn it on. + TAILSCALE: False, **variables, - }}}})) + } + # A variable given as None is left out: ansible_become is False above so that no test needs sudo, and a test that watches which tasks ask for it passes None for it. + inventory.write_text(yaml.safe_dump({"all": {"hosts": {"host": {name: value for name, value in host.items() if value is not None}}}})) env = { **os.environ, "PATH": f"{self.bin}:{os.environ['PATH']}", @@ -718,5 +752,145 @@ def test_a_check_run_of_the_upgrade_simulates_and_reports_the_reboot(self) -> No self.assertTrue((self.state / "upgradable").exists()) +class TailscaleUpdateTest(Harness): + """The Tailscale step: the installed package kept current and its service running, and nothing installed.""" + + def tailscale(self, installed: str | None = "1.100.0", candidate: str | None = "1.104.4", enabled: bool = True, active: bool = True) -> None: + """A host with the package at `installed` (None for not installed) while apt's newest release is `candidate` (None for no repository carrying it), and the tailscaled service enabled and active as given.""" + if installed is not None: + (self.state / "tailscale-installed").write_text(installed) + if candidate is not None: + (self.state / "tailscale-candidate").write_text(candidate) + if enabled: + (self.state / "enabled-tailscaled").write_text("enabled\n") + if active: + (self.state / "active-tailscaled").touch() + + def installed(self) -> str: + return (self.state / "tailscale-installed").read_text() + + def update(self, *, check: bool = False, **variables: Any) -> subprocess.CompletedProcess[str]: + # become goes through the stand-in sudo, whose calls show which tasks ask for privilege. + result = self.run_role(check=check, github_runner_linux_host_tailscale_update=True, ansible_become=None, ansible_become_exe=str(self.bin / "sudo"), **variables) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result + + def sudo_calls(self) -> list[str]: + return [call for call in self.calls() if call.startswith("sudo ")] + + def writes(self) -> list[str]: + """The calls that change the host, without the reads and the index refresh.""" + return [call for call in self.calls() if re.match(r"^(apt-get (?!update)|systemctl (enable|disable|stop|start|restart|mask)|dpkg )", call) and not call.startswith("dpkg --compare-versions")] + + def test_an_older_package_gets_that_package_alone_upgraded_and_nothing_else(self) -> None: + self.tailscale() + result = self.update() + self.assertEqual(self.writes(), ["apt-get install --yes --no-install-recommends --only-upgrade tailscale"]) + self.assertEqual(self.installed(), "1.104.4") + self.assertEqual(self.changed(result), 1) + self.assertEqual([call for call in self.calls() if call.startswith("apt-get")], ["apt-get update", "apt-get install --yes --no-install-recommends --only-upgrade tailscale"]) + self.assertEqual(len(self.sudo_calls()), 2, "the index refresh and the upgrade take root, and nothing else does") + + def test_a_second_run_changes_nothing(self) -> None: + self.tailscale() + self.update() + before = self.writes() + result = self.update() + self.assertEqual(self.writes(), before) + self.assertEqual(self.changed(result), 0) + + def test_a_package_already_at_the_newest_release_is_not_touched(self) -> None: + self.tailscale(installed="1.104.4") + result = self.update() + self.assertEqual(self.writes(), []) + self.assertEqual(self.changed(result), 0) + + def test_a_package_newer_than_the_repositorys_is_not_downgraded(self) -> None: + self.tailscale(installed="1.106.0", candidate="1.104.4") + result = self.update() + self.assertEqual(self.writes(), []) + self.assertEqual(self.installed(), "1.106.0") + self.assertEqual(self.changed(result), 0) + + def test_a_package_no_repository_carries_is_not_touched(self) -> None: + self.tailscale(candidate=None) + result = self.update() + self.assertEqual(self.writes(), []) + self.assertEqual(self.changed(result), 0) + + def test_check_mode_reports_the_upgrade_and_changes_nothing(self) -> None: + self.tailscale() + result = self.update(check=True) + self.assertEqual(self.writes(), []) + self.assertEqual(self.sudo_calls(), [], "a dry run needs no privilege") + self.assertEqual([call for call in self.calls() if call.startswith("apt-get")], []) + self.assertEqual(self.installed(), "1.100.0") + self.assertEqual(self.changed(result), 1) + self.assertIn("apt-get install --only-upgrade tailscale, from 1.100.0 to 1.104.4", result.stdout) + + def test_with_the_variable_false_nothing_is_read_or_changed(self) -> None: + self.tailscale(active=False) + result = self.run_role() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.calls(), []) + self.assertEqual(self.installed(), "1.100.0") + + def test_a_host_without_the_package_is_skipped_with_a_message_and_nothing_is_installed(self) -> None: + self.tailscale(installed=None) + result = self.update() + self.assertEqual(self.writes(), []) + self.assertFalse((self.state / "tailscale-installed").exists()) + self.assertIn("the package tailscale is not installed", result.stdout) + self.assertEqual(self.changed(result), 0) + + def test_a_host_that_is_not_debian_is_skipped_with_a_message(self) -> None: + self.tailscale() + result = self.update(os_family="RedHat") + self.assertEqual(self.calls(), []) + self.assertIn("the package is apt's", result.stdout) + + def test_a_stopped_or_disabled_service_is_enabled_and_started_once(self) -> None: + for enabled, active in ((False, True), (True, False), (False, False)): + with self.subTest(enabled=enabled, active=active): + for name in ("enabled-tailscaled", "active-tailscaled"): + (self.state / name).unlink(missing_ok=True) + self.tailscale(installed="1.104.4", enabled=enabled, active=active) + (self.stubs / "calls.log").unlink(missing_ok=True) + result = self.update() + self.assertEqual(self.writes(), ["systemctl enable --now tailscaled"]) + self.assertEqual(len(self.sudo_calls()), 2, "the index refresh and the service change") + self.assertEqual(self.changed(result), 1) + self.assertEqual((self.state / "enabled-tailscaled").read_text(), "enabled\n") + self.assertTrue((self.state / "active-tailscaled").exists()) + (self.stubs / "calls.log").unlink(missing_ok=True) + self.assertEqual(self.changed(self.update()), 0) + self.assertEqual(self.writes(), []) + + def test_check_mode_reports_the_service_change_and_makes_none(self) -> None: + self.tailscale(installed="1.104.4", active=False) + result = self.update(check=True) + self.assertEqual(self.writes(), []) + self.assertEqual(self.sudo_calls(), [], "a dry run needs no privilege") + self.assertEqual(self.changed(result), 1) + self.assertFalse((self.state / "active-tailscaled").exists()) + + def test_a_native_node_of_the_cluster_is_left_to_the_cluster_role(self) -> None: + self.tailscale() + result = self.update(github_runner_cluster_node_mode="native") + self.assertEqual(self.writes(), []) + self.assertEqual(self.installed(), "1.100.0") + self.assertIn("whose cluster role installs the release it pins", result.stdout) + + def test_a_native_node_whose_cluster_role_does_not_install_tailscale_is_kept_current(self) -> None: + self.tailscale() + self.update(github_runner_cluster_node_mode="native", github_runner_cluster_native_tailscale_install=False) + self.assertEqual(self.installed(), "1.104.4") + + def test_a_node_in_compose_mode_is_kept_current(self) -> None: + self.tailscale() + self.update(github_runner_cluster_node_mode="compose") + self.assertEqual(self.installed(), "1.104.4") + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_macos_host.py b/tests/unit/test_macos_host.py index 4c6b5866..7f65a274 100644 --- a/tests/unit/test_macos_host.py +++ b/tests/unit/test_macos_host.py @@ -164,6 +164,20 @@ def test_passwordless_sudo_is_off_by_default_and_is_for_the_connecting_user(self self.assertEqual(DEFAULTS["github_runner_macos_host_sudoers_user"], "") self.assertEqual(DEFAULTS["github_runner_macos_host_sudoers_dir"], "/etc/sudoers.d") + def test_the_tailscale_software_is_kept_current_by_default_and_only_when_it_is_installed(self) -> None: + 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")) + 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"]) + 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_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"], []) @@ -266,7 +280,7 @@ def test_become_is_used_only_inside_the_role_and_only_on_the_tasks_that_change_t for path in sorted(ROLE.glob("tasks/*.yml")): text = path.read_text() with self.subTest(path=path.name): - self.assertEqual(len(re.findall(r"^\s*become: true", text, re.MULTILINE)), {"power.yml": 2, "sudoers.yml": 1, "tailscale.yml": 2}.get(path.name, 0)) + self.assertEqual(len(re.findall(r"^\s*become: true", text, re.MULTILINE)), {"power.yml": 2, "sudoers.yml": 1, "tailscale.yml": 2, "tailscale_update.yml": 1}.get(path.name, 0)) def test_every_task_that_runs_on_the_host_is_guarded_to_macos(self) -> None: tasks = yaml.safe_load((ROLE / "tasks" / "main.yml").read_text()) @@ -331,25 +345,51 @@ def need_root(): log("brew root=" + str(int(root)) + " " + " ".join(args)) if root: sys.exit("Running Homebrew as root is extremely dangerous and no longer supported.") - if args[:2] == ["info", "--json=v2"] and args[2:3] == ["tailscale"]: - installed = [{{"version": "1.0"}}] if os.path.exists(path("brew.installed")) else [] - print(json.dumps({{"formulae": [{{"name": "tailscale", "full_name": "tailscale", "tap": "homebrew/core", "installed": installed, "outdated": False}}], "casks": []}})) - elif args[:2] == ["install", "tailscale"]: - write("brew.installed") + if args == ["--version"]: + print("Homebrew 4.6.0") + elif args == ["update", "--quiet"]: + pass + 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[:1] in (["install"], ["upgrade"]) and args[1:] == ["tailscale"]: + 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) + 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"]: + if "--greedy" not in args: + sys.exit(0) + if read("cask.installed") != read("cask.latest", "1.2.0"): + print("tailscale-app") + elif args == ["upgrade", "--cask", "tailscale-app"]: + write("cask.installed", read("cask.latest", "1.2.0")) else: sys.exit("unexpected brew call: " + " ".join(args)) elif name == "tailscaled": - log("tailscaled root=" + str(int(root)) + " " + " ".join(args)) - if args != ["install-system-daemon"]: + system = os.path.abspath(sys.argv[0]) == os.environ["MACOS_HOST_DAEMON"] + log(("daemon" if system else "tailscaled") + " root=" + str(int(root)) + " " + " ".join(args)) + if args == ["--version"]: + # The system daemon is a copy of the formula's tailscaled, or a link to it where the daemon path is a link. + print(read("formula.installed") if not system or os.path.exists(path("daemon.link")) else read("daemon.version")) + print(" go version: go1.0") + sys.exit(0) + if system or args != ["install-system-daemon"]: sys.exit("unexpected tailscaled call: " + " ".join(args)) need_root() write("LaunchDaemons/com.tailscale.tailscaled.plist", "") write("daemon.up") + write("daemon.running", read("formula.installed")) + if not os.path.exists(path("daemon.link")): + write("daemon.version", read("formula.installed")) + target = os.environ["MACOS_HOST_DAEMON"] + os.makedirs(os.path.dirname(target), exist_ok=True) + open(target, "w").write(open(sys.argv[0]).read()) + os.chmod(target, 0o755) if not os.path.exists(path("login.state")): write("login.state", "NeedsLogin") elif name == "tailscale": @@ -357,7 +397,7 @@ def need_root(): if not os.path.exists(path("daemon.up")): sys.exit("failed to connect to local tailscaled") if args[:1] == ["status"]: - print(json.dumps({{"BackendState": read("login.state")}})) + print(json.dumps({{"BackendState": read("login.state"), "Version": read("daemon.running") + "-tabcdef-gabcdef"}})) elif args[:1] == ["up"]: need_root() key = sys.stdin.read() @@ -394,6 +434,8 @@ def setUp(self) -> None: 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. + self.daemon_binary = self.stub / "usr-local-bin" / "tailscaled" self.set_power(sleepdisabled="0", sleep="1", displaysleep="10", disksleep="10") def set_power(self, sleepdisabled: str | None = "1", **overrides: str) -> None: @@ -416,12 +458,43 @@ 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.""" + for tool in ("tailscaled", "tailscale"): + (self.bin / tool).write_text(STUB.format(python=sys.executable)) + (self.bin / tool).chmod(0o755) + (self.stub / "formula.installed").write_text(installed) + (self.stub / "formula.latest").write_text(latest) + (self.stub / "daemon.running").write_text(running or installed) + (self.stub / "daemon.version").write_text(binary or installed) + (self.stub / "login.state").write_text("Running") + (self.stub / "daemon.up").write_text("") + if link: + (self.stub / "daemon.link").write_text("") + self.daemon_binary.parent.mkdir(parents=True) + self.daemon_binary.write_text(STUB.format(python=sys.executable)) + self.daemon_binary.chmod(0o755) + 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.""" + if installed is not None: + (self.stub / "cask.installed").write_text(installed) + (self.stub / "cask.latest").write_text(latest) + + def state(self, name: str) -> str: + return (self.stub / name).read_text() + def run_role(self, *extra: str, system: str = "Darwin", machine: str = "arm64", **variables: Any) -> subprocess.CompletedProcess[str]: host = { "host_system": system, "host_machine": machine, "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), + # 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, } inventory = self.work / "inventory.yml" @@ -433,6 +506,7 @@ def run_role(self, *extra: str, system: str = "Darwin", machine: str = "arm64", "ANSIBLE_INVENTORY_UNPARSED_WARNING": "false", "ANSIBLE_RETRY_FILES_ENABLED": "false", "MACOS_HOST_STUB": str(self.stub), + "MACOS_HOST_DAEMON": str(self.daemon_binary), # 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']}", } @@ -452,7 +526,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 .*|tailscale root=0 status .*)$") + 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 .*)$") 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]: @@ -685,7 +759,7 @@ def test_check_mode_reports_the_daemon_install_and_installs_nothing(self) -> Non self.assertEqual(self.writes(), []) self.assertEqual(self.sudo_calls(), [], "a dry run needs no privilege") self.assertFalse(self.plist.exists()) - self.assertFalse((self.stub / "brew.installed").exists()) + self.assertFalse((self.stub / "formula.installed").exists()) self.assertEqual(self.recap(result)["changed"], 2, result.stdout) def test_check_mode_with_an_auth_key_on_a_host_without_the_daemon_joins_nothing(self) -> None: @@ -705,6 +779,164 @@ def test_the_prefix_follows_the_machine_unless_it_is_set(self) -> None: rendered = environment.from_string(expression).render(ansible_facts={"machine": machine}, github_runner_macos_host_homebrew_prefix=prefix_set) self.assertEqual(rendered.strip(), expected) + # Keeping the Tailscale software current. + + def update_role(self, *extra: str, **variables: Any) -> subprocess.CompletedProcess[str]: + self.set_power() + return self.run_role(*extra, github_runner_macos_host_tailscale_update=True, **variables) + + def test_a_formula_and_daemon_already_current_are_left_alone_and_not_restarted(self) -> None: + self.formula_daemon() + result = self.update_role() + self.ok(result) + self.assertEqual(self.writes(), []) + self.assertEqual(self.sudo_calls(), []) + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + + def test_an_older_formula_and_daemon_get_one_upgrade_of_the_formula_alone_and_one_refresh(self) -> None: + self.formula_daemon(installed="1.1.0", latest="1.2.0") + 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")) + self.assertEqual(self.recap(result)["changed"], 2, result.stdout) + self.assertEqual(self.state("login.state"), "Running") + self.assertFalse([call for call in self.calls() if call.startswith("tailscale root=1 up")]) + + def test_after_the_refresh_a_second_run_changes_nothing(self) -> None: + self.formula_daemon(installed="1.1.0", latest="1.2.0") + self.update_role() + before = self.writes() + result = self.update_role() + self.ok(result) + self.assertEqual(self.writes(), before) + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + + def test_a_formula_upgraded_by_hand_still_gets_the_daemon_refreshed(self) -> None: + self.formula_daemon(installed="1.2.0", latest="1.2.0", running="1.1.0", binary="1.1.0") + 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") + + def test_a_daemon_path_that_links_to_the_formula_is_refreshed_when_the_process_is_still_the_old_release(self) -> None: + self.formula_daemon(installed="1.2.0", latest="1.2.0", running="1.1.0", link=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") + + def test_a_missing_daemon_binary_is_put_back(self) -> None: + self.formula_daemon() + self.daemon_binary.unlink() + result = self.update_role() + self.ok(result) + self.assertEqual(self.writes(), ["tailscaled root=1 install-system-daemon"]) + self.assertTrue(self.daemon_binary.exists()) + + def test_a_daemon_that_does_not_answer_is_compared_by_its_binary_alone(self) -> None: + self.formula_daemon() + (self.stub / "daemon.up").unlink() + result = self.update_role() + self.ok(result) + self.assertEqual(self.writes(), []) + + def test_check_mode_reports_the_upgrade_and_the_refresh_and_makes_no_change(self) -> None: + self.formula_daemon(installed="1.1.0", latest="1.2.0") + result = self.update_role("--check") + self.ok(result) + self.assertEqual(self.writes(), []) + self.assertEqual(self.sudo_calls(), [], "a dry run needs no privilege") + self.assertEqual((self.state("formula.installed"), self.state("daemon.version")), ("1.1.0", "1.1.0")) + self.assertEqual(self.recap(result)["changed"], 2, result.stdout) + self.assertIn("tailscaled install-system-daemon, to run the formula's release", result.stdout) + + def test_check_mode_with_everything_current_reports_nothing(self) -> None: + self.formula_daemon() + self.app() + result = self.update_role("--check") + self.ok(result) + self.assertEqual(self.writes(), []) + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + + def test_with_the_variable_false_nothing_is_asked_of_homebrew_or_the_daemon(self) -> None: + self.formula_daemon(installed="1.1.0", latest="1.2.0") + self.app(installed="1.1.0", latest="1.2.0") + self.set_power() + result = self.run_role(github_runner_macos_host_tailscale_update=False) + self.ok(result) + self.assertEqual(self.calls(), READS) + self.assertEqual((self.state("formula.installed"), self.state("cask.installed")), ("1.1.0", "1.1.0")) + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + + def test_the_standalone_app_is_upgraded_when_it_is_installed_as_the_cask(self) -> None: + self.formula_daemon() + self.app(installed="1.1.0", latest="1.2.0") + result = self.update_role() + self.ok(result) + self.assertEqual(self.writes(), ["brew root=0 upgrade --cask tailscale-app"]) + self.assertEqual(self.state("cask.installed"), "1.2.0") + self.assertIn("brew root=0 outdated --cask --greedy tailscale-app", self.calls()) + self.assertEqual(self.recap(result)["changed"], 1, result.stdout) + + def test_the_standalone_app_is_never_installed(self) -> None: + self.formula_daemon() + self.app(installed=None, latest="1.2.0") + result = self.update_role() + self.ok(result) + self.assertEqual(self.writes(), []) + self.assertFalse((self.stub / "cask.installed").exists()) + self.assertIn("brew root=0 list --cask tailscale-app", self.calls()) + + def test_check_mode_reports_the_app_upgrade_and_makes_no_call_that_changes_it(self) -> None: + self.formula_daemon() + self.app(installed="1.1.0", latest="1.2.0") + result = self.update_role("--check") + self.ok(result) + self.assertEqual(self.writes(), []) + self.assertEqual(self.state("cask.installed"), "1.1.0") + self.assertEqual(self.recap(result)["changed"], 1, result.stdout) + + def test_the_app_alone_is_kept_current_beside_a_daemon_built_by_hand(self) -> None: + self.hand_built_daemon() + self.app(installed="1.1.0", latest="1.2.0") + result = self.update_role() + self.ok(result) + self.assertEqual(self.writes(), ["brew root=0 upgrade --cask tailscale-app"]) + self.assertFalse((self.stub / "formula.installed").exists()) + + def test_a_daemon_built_by_hand_with_no_formula_or_app_is_skipped_with_a_message(self) -> None: + self.hand_built_daemon() + result = self.update_role(github_runner_macos_host_tailscale_auth_key=AUTH_KEY) + self.ok(result) + self.assertEqual(self.writes(), []) + self.assertEqual(self.sudo_calls(), []) + self.assertIn("Tailscale is not updated", result.stdout) + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + + def test_a_host_without_homebrew_is_skipped_and_the_run_does_not_fail(self) -> None: + self.hand_built_daemon() + (self.bin / "brew").unlink() + result = self.update_role() + self.ok(result) + self.assertIn("Tailscale is not updated", result.stdout) + self.assertEqual(self.writes(), []) + + def test_a_first_install_gets_the_latest_release_from_the_install_and_is_not_upgraded_again(self) -> None: + (self.stub / "formula.latest").write_text("1.2.0") + result = self.update_role() + self.ok(result) + self.assertEqual(self.writes(), ["brew root=0 install tailscale", "tailscaled root=1 install-system-daemon"]) + self.assertEqual(self.state("daemon.version"), "1.2.0") + + def test_the_update_never_logs_in_even_with_the_daemon_waiting_for_one(self) -> None: + self.formula_daemon(installed="1.1.0", latest="1.2.0") + (self.stub / "login.state").write_text("NeedsLogin") + result = self.update_role() + self.ok(result) + self.assertEqual(self.state("login.state"), "NeedsLogin") + self.assertFalse([call for call in self.calls() if call.startswith("tailscale ") and " up" in call]) + # Joining a tailnet. def test_an_auth_key_joins_once_with_the_key_on_standard_input_and_nowhere_else(self) -> None: