From 7c6d57da4cf1e5662e895dcadf736da256de9c38 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 2 Nov 2026 19:50:12 +0000 Subject: [PATCH] fix(macos-host): install the daemon beside the standalone Tailscale app The fleet's Macs run the standalone app and the tailscaled system daemon side by side, and they coexist once the app's network extension is current. Skipping the daemon wherever the app is installed left such a host without the daemon the role is meant to provide. --- roles/github_runner_macos_host/README.md | 3 +-- roles/github_runner_macos_host/defaults/main.yml | 2 -- .../meta/argument_specs.yml | 4 ---- roles/github_runner_macos_host/tasks/tailscale.yml | 13 ++----------- tests/unit/test_macos_host.py | 12 ------------ 5 files changed, 3 insertions(+), 31 deletions(-) diff --git a/roles/github_runner_macos_host/README.md b/roles/github_runner_macos_host/README.md index add92179..314013ca 100644 --- a/roles/github_runner_macos_host/README.md +++ b/roles/github_runner_macos_host/README.md @@ -33,7 +33,7 @@ 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 not installed on again, whether this role or a person installed it, and neither is a host that has Tailscale's standalone app (`/Applications/Tailscale.app`), which is its own Tailscale client in a system network extension and would conflict with a second daemon; 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. 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. @@ -57,7 +57,6 @@ Replacing the daemon restarts it, so connections over the host's tailnet, an SSH - `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_app_bundle`: where Tailscale's standalone app lives. A host that has it gets no daemon installed beside it. Not a setting; a variable only so the tests can point it at a stand-in. - `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. diff --git a/roles/github_runner_macos_host/defaults/main.yml b/roles/github_runner_macos_host/defaults/main.yml index 983b8572..afebf696 100644 --- a/roles/github_runner_macos_host/defaults/main.yml +++ b/roles/github_runner_macos_host/defaults/main.yml @@ -39,8 +39,6 @@ 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 -# Where the standalone Tailscale app lives. A host that has it is its own Tailscale client (a system network extension), so the role installs no daemon beside it. Not a setting: 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 9bb941f8..78be1ff5 100644 --- a/roles/github_runner_macos_host/meta/argument_specs.yml +++ b/roles/github_runner_macos_host/meta/argument_specs.yml @@ -82,10 +82,6 @@ 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 Tailscale's standalone app lives. A host that has it is its own Tailscale client, so the role installs no daemon beside it. 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 4196ab8a..00b63a0a 100644 --- a/roles/github_runner_macos_host/tasks/tailscale.yml +++ b/roles/github_runner_macos_host/tasks/tailscale.yml @@ -13,18 +13,9 @@ path: "{{ github_runner_macos_host_tailscale_plist }}" register: github_runner_macos_host_tailscale_daemon -- name: Look for the standalone Tailscale app - # The app carries its own client in a system network extension, which the formula's daemon would conflict with (both want the host's one tailnet connection), so a host that has the app is its own Tailscale client already. - ansible.builtin.stat: - path: "{{ github_runner_macos_host_tailscale_app_bundle }}" - get_checksum: false - register: github_runner_macos_host_tailscale_app_bundle_stat - -# A host that already has the definition, installed by this role earlier or by hand, or the standalone app, is left alone: nothing below runs, so a hand-built daemon is never replaced by the formula's and the app is never given a second client. +# A host that already has the definition, installed by this role earlier or by hand, is left alone: nothing below runs, so a hand-built daemon is never replaced by the formula's. - name: Install the daemon - when: - - not github_runner_macos_host_tailscale_daemon.stat.exists - - not github_runner_macos_host_tailscale_app_bundle_stat.stat.exists + when: not github_runner_macos_host_tailscale_daemon.stat.exists block: - name: Install the Tailscale Homebrew formula community.general.homebrew: diff --git a/tests/unit/test_macos_host.py b/tests/unit/test_macos_host.py index 8211f3b4..7f65a274 100644 --- a/tests/unit/test_macos_host.py +++ b/tests/unit/test_macos_host.py @@ -436,8 +436,6 @@ def setUp(self) -> None: 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" - # Where the standalone app would be; absent unless a test creates it. - self.app_bundle = self.stub / "Applications" / "Tailscale.app" self.set_power(sleepdisabled="0", sleep="1", displaysleep="10", disksleep="10") def set_power(self, sleepdisabled: str | None = "1", **overrides: str) -> None: @@ -495,7 +493,6 @@ 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, @@ -741,15 +738,6 @@ def test_a_host_that_already_has_the_daemon_is_left_entirely_alone(self) -> None self.assertEqual(self.calls(), READS) self.assertEqual(self.recap(result)["changed"], 0) - def test_a_host_running_the_standalone_app_gets_no_daemon_beside_it(self) -> None: - self.set_power() - self.app_bundle.mkdir(parents=True) - result = self.run_role() - self.ok(result) - self.assertEqual(self.calls(), READS) - self.assertFalse(self.plist.exists()) - self.assertEqual(self.recap(result)["changed"], 0) - def test_a_hand_built_daemon_stays_alone_even_with_an_auth_key_and_no_formula(self) -> None: self.set_power() self.hand_built_daemon()