diff --git a/roles/github_runner_macos_host/README.md b/roles/github_runner_macos_host/README.md index 537c228d..add92179 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 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. 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 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,6 +57,7 @@ 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 afebf696..983b8572 100644 --- a/roles/github_runner_macos_host/defaults/main.yml +++ b/roles/github_runner_macos_host/defaults/main.yml @@ -39,6 +39,8 @@ 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 78be1ff5..9bb941f8 100644 --- a/roles/github_runner_macos_host/meta/argument_specs.yml +++ b/roles/github_runner_macos_host/meta/argument_specs.yml @@ -82,6 +82,10 @@ argument_specs: type: str default: tailscale-app description: "The Homebrew cask of Tailscale's standalone app, kept current only when it is installed. Not a setting, and a variable only so the tests can point it at a stand-in." + github_runner_macos_host_tailscale_app_bundle: + type: path + default: /Applications/Tailscale.app + description: "Where 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 00b63a0a..4196ab8a 100644 --- a/roles/github_runner_macos_host/tasks/tailscale.yml +++ b/roles/github_runner_macos_host/tasks/tailscale.yml @@ -13,9 +13,18 @@ path: "{{ github_runner_macos_host_tailscale_plist }}" register: github_runner_macos_host_tailscale_daemon -# 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: 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. - name: Install the daemon - when: not github_runner_macos_host_tailscale_daemon.stat.exists + when: + - not github_runner_macos_host_tailscale_daemon.stat.exists + - not github_runner_macos_host_tailscale_app_bundle_stat.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 7f65a274..8211f3b4 100644 --- a/tests/unit/test_macos_host.py +++ b/tests/unit/test_macos_host.py @@ -436,6 +436,8 @@ 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: @@ -493,6 +495,7 @@ def run_role(self, *extra: str, system: str = "Darwin", machine: str = "arm64", "github_runner_macos_host_homebrew_prefix": str(self.prefix), "github_runner_macos_host_tailscale_plist": str(self.plist), "github_runner_macos_host_tailscale_daemon_binary": str(self.daemon_binary), + "github_runner_macos_host_tailscale_app_bundle": str(self.app_bundle), # The install and join tests are about those, and a stand-in formula that is installed would otherwise be upgraded on their second runs: the update has tests of its own, which turn it on. "github_runner_macos_host_tailscale_update": False, **variables, @@ -738,6 +741,15 @@ 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()