diff --git a/roles/github_runner_macos_actions_runner/files/promote.py b/roles/github_runner_macos_actions_runner/files/promote.py new file mode 100644 index 00000000..ff825304 --- /dev/null +++ b/roles/github_runner_macos_actions_runner/files/promote.py @@ -0,0 +1,22 @@ +#!/usr/bin/python3 +"""Atomically publish a staged runner without replacing an existing path.""" + +import ctypes +import os +import sys + + +def promote(source: str, destination: str) -> None: + """Rename a directory exclusively on Darwin, raising on any failure.""" + libc = ctypes.CDLL(None, use_errno=True) + rename = libc.renamex_np + rename.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint] + rename.restype = ctypes.c_int + # Darwin's RENAME_EXCL refuses an existing destination, including empty directories. + if rename(os.fsencode(source), os.fsencode(destination), 0x00000004) != 0: + error = ctypes.get_errno() + raise OSError(error, os.strerror(error), destination) + + +if __name__ == "__main__": + promote(sys.argv[1], sys.argv[2]) diff --git a/roles/github_runner_macos_actions_runner/handlers/main.yml b/roles/github_runner_macos_actions_runner/handlers/main.yml index c54d58f4..3e758198 100644 --- a/roles/github_runner_macos_actions_runner/handlers/main.yml +++ b/roles/github_runner_macos_actions_runner/handlers/main.yml @@ -1,12 +1,4 @@ --- -- name: Stop the previous macOS Actions runner launch daemon - listen: Restart macOS Actions runner - become: true - ansible.builtin.command: - argv: [launchctl, bootout, "system/{{ github_runner_macos_actions_runner_launchd_label }}"] - failed_when: false - changed_when: false - - name: Start the macOS Actions runner launch daemon listen: Restart macOS Actions runner become: true diff --git a/roles/github_runner_macos_actions_runner/tasks/main.yml b/roles/github_runner_macos_actions_runner/tasks/main.yml index 6c1bc7eb..cbd72bde 100644 --- a/roles/github_runner_macos_actions_runner/tasks/main.yml +++ b/roles/github_runner_macos_actions_runner/tasks/main.yml @@ -12,6 +12,66 @@ ansible.builtin.meta: end_role when: ansible_facts['system'] != 'Darwin' +- name: Inspect the runner directory before making changes + become: true + ansible.builtin.stat: + path: "{{ github_runner_macos_actions_runner_root }}" + follow: false + register: github_runner_macos_actions_runner_directory + +- name: Inspect the runner registration before making changes + become: true + ansible.builtin.stat: + path: "{{ github_runner_macos_actions_runner_root }}/.runner" + follow: false + register: github_runner_macos_actions_runner_registration + +- name: Inspect the launchd service before making changes + become: true + ansible.builtin.command: + argv: [launchctl, print, "system/{{ github_runner_macos_actions_runner_launchd_label }}"] + register: github_runner_macos_actions_runner_service + changed_when: false + failed_when: false + check_mode: false + +- name: Refuse unknown launchd service state + ansible.builtin.assert: + that: + - github_runner_macos_actions_runner_service.rc in [0, 113] + fail_msg: Cannot establish whether the macOS runner service exists; leaving it untouched. + +- name: Refuse disabling an existing runner + ansible.builtin.assert: + that: + - not github_runner_macos_actions_runner_directory.stat.exists + - not github_runner_macos_actions_runner_registration.stat.exists + - github_runner_macos_actions_runner_service.rc == 113 + fail_msg: Disabling an existing macOS runner is unsupported until a race-free mechanism exists. + when: not (github_runner_macos_actions_runner_enabled | bool) + +- name: End the role when disabled and absent + ansible.builtin.meta: end_role + when: not (github_runner_macos_actions_runner_enabled | bool) + +- name: Refuse an existing runner registration + ansible.builtin.assert: + that: + - not github_runner_macos_actions_runner_registration.stat.exists + fail_msg: An existing macOS runner registration cannot be safely replaced; leaving it untouched. + +- name: Refuse an existing launchd service + ansible.builtin.assert: + that: + - github_runner_macos_actions_runner_service.rc == 113 + fail_msg: An existing macOS runner service cannot be safely replaced. + +- name: Refuse any existing runner path + ansible.builtin.assert: + that: + - not github_runner_macos_actions_runner_directory.stat.exists + fail_msg: The runner path already exists; leaving it untouched. + - name: Read the macOS runner App key from 1Password delegate_to: localhost ansible.builtin.command: @@ -44,20 +104,6 @@ when: github_runner_macos_actions_runner_enabled | bool no_log: true -- name: Stop and remove a disabled macOS runner - become: true - when: not (github_runner_macos_actions_runner_enabled | bool) - block: - - name: Remove the runner launch daemon - ansible.builtin.file: - path: "{{ github_runner_macos_actions_runner_plist }}" - state: absent - - name: Stop the disabled runner service - ansible.builtin.command: - argv: [launchctl, bootout, "system/{{ github_runner_macos_actions_runner_launchd_label }}"] - failed_when: false - changed_when: false - - name: Configure the enabled macOS runner when: github_runner_macos_actions_runner_enabled | bool block: @@ -102,74 +148,61 @@ quiet: true when: not ansible_check_mode - - name: Create the runner directory + - name: Create the runner parent directory become: true ansible.builtin.file: - path: "{{ github_runner_macos_actions_runner_root }}" + path: "{{ github_runner_macos_actions_runner_root | dirname }}" state: directory - owner: "{{ github_runner_macos_actions_runner_user }}" - mode: "0750" - - - name: Check the installed runner version - become: true - become_user: "{{ github_runner_macos_actions_runner_user }}" - ansible.builtin.command: - argv: ["{{ github_runner_macos_actions_runner_root }}/bin/Runner.Listener", --version] - register: github_runner_macos_actions_runner_installed_version - changed_when: false - failed_when: false - check_mode: false - when: not ansible_check_mode or github_runner_macos_actions_runner_account.rc == 0 + mode: "0755" - - name: Download the pinned Apple Silicon runner package - become: true - ansible.builtin.get_url: - url: "https://github.com/actions/runner/releases/download/v{{ github_runner_macos_actions_runner_version }}/actions-runner-osx-arm64-{{ github_runner_macos_actions_runner_version }}.tar.gz" - dest: "{{ github_runner_macos_actions_runner_root }}-{{ github_runner_macos_actions_runner_version }}.tar.gz" - checksum: "sha256:{{ github_runner_macos_actions_runner_sha256 }}" - mode: "0644" - when: - - not ansible_check_mode - - github_runner_macos_actions_runner_installed_version.stdout | default('') | trim != github_runner_macos_actions_runner_version - - - name: Stop the runner before replacing its installation - become: true - ansible.builtin.command: - argv: [launchctl, bootout, "system/{{ github_runner_macos_actions_runner_launchd_label }}"] - failed_when: false - changed_when: false - when: github_runner_macos_actions_runner_installed_version.stdout | default('') | trim != github_runner_macos_actions_runner_version + - name: Stage and publish the first runner installation + when: not ansible_check_mode + block: + - name: Create a private staging directory + become: true + ansible.builtin.tempfile: + state: directory + path: "{{ github_runner_macos_actions_runner_root | dirname }}" + prefix: .github-runner-stage- + register: github_runner_macos_actions_runner_stage - - name: Remove the prior runner installation - ansible.builtin.file: - path: "{{ github_runner_macos_actions_runner_root }}" - state: absent - when: github_runner_macos_actions_runner_installed_version.stdout | default('') | trim != github_runner_macos_actions_runner_version + - name: Download the pinned Apple Silicon runner package + become: true + ansible.builtin.get_url: + url: "https://github.com/actions/runner/releases/download/v{{ github_runner_macos_actions_runner_version }}/actions-runner-osx-arm64-{{ github_runner_macos_actions_runner_version }}.tar.gz" + dest: "{{ github_runner_macos_actions_runner_stage.path }}/runner.tar.gz" + checksum: "sha256:{{ github_runner_macos_actions_runner_sha256 }}" + mode: "0600" - - name: Recreate the runner directory - become: true - ansible.builtin.file: - path: "{{ github_runner_macos_actions_runner_root }}" - state: directory - owner: "{{ github_runner_macos_actions_runner_user }}" - mode: "0750" - when: github_runner_macos_actions_runner_installed_version.stdout | default('') | trim != github_runner_macos_actions_runner_version + - name: Create the staged runner directory + become: true + ansible.builtin.file: + path: "{{ github_runner_macos_actions_runner_stage.path }}/runner" + state: directory + owner: "{{ github_runner_macos_actions_runner_user }}" + mode: "0750" - - name: Install the pinned Apple Silicon runner package - become: true - ansible.builtin.unarchive: - src: "{{ github_runner_macos_actions_runner_root }}-{{ github_runner_macos_actions_runner_version }}.tar.gz" - dest: "{{ github_runner_macos_actions_runner_root }}" - remote_src: true - owner: "{{ github_runner_macos_actions_runner_user }}" - when: - - not ansible_check_mode - - github_runner_macos_actions_runner_installed_version.stdout | default('') | trim != github_runner_macos_actions_runner_version + - name: Install the pinned Apple Silicon runner package + become: true + ansible.builtin.unarchive: + src: "{{ github_runner_macos_actions_runner_stage.path }}/runner.tar.gz" + dest: "{{ github_runner_macos_actions_runner_stage.path }}/runner" + remote_src: true + owner: "{{ github_runner_macos_actions_runner_user }}" - - name: Check whether the runner is registered - ansible.builtin.stat: - path: "{{ github_runner_macos_actions_runner_root }}/.runner" - register: github_runner_macos_actions_runner_registration + - name: Atomically publish the runner without replacing a path + become: true + ansible.builtin.script: + cmd: "{{ role_path }}/files/promote.py {{ (github_runner_macos_actions_runner_stage.path ~ '/runner') | quote }} {{ github_runner_macos_actions_runner_root | quote }}" + executable: /usr/bin/python3 + changed_when: true + always: + - name: Remove the private staging directory + become: true + ansible.builtin.file: + path: "{{ github_runner_macos_actions_runner_stage.path }}" + state: absent + when: github_runner_macos_actions_runner_stage.path is defined - name: Register the runner through the GitHub App delegate_to: localhost @@ -245,7 +278,6 @@ - "{{ github_runner_macos_actions_runner_label }}" - --work - "{{ github_runner_macos_actions_runner_work_dir }}" - - --replace environment: ACTIONS_RUNNER_INPUT_TOKEN: "{{ github_runner_macos_actions_runner_registration_token.json.token }}" no_log: true diff --git a/tests/unit/test_macos_actions_runner_check.py b/tests/unit/test_macos_actions_runner_check.py index 1902ac09..94433c06 100644 --- a/tests/unit/test_macos_actions_runner_check.py +++ b/tests/unit/test_macos_actions_runner_check.py @@ -1,4 +1,4 @@ -"""Check-mode regression tests for a macOS runner on a host with no runner account.""" +"""Safety checks for first-install-only macOS runner provisioning.""" from pathlib import Path import unittest @@ -10,19 +10,40 @@ class MacOSActionsRunnerCheckTest(unittest.TestCase): - def test_first_install_does_not_become_the_not_yet_created_user(self) -> None: - tasks = yaml.safe_load((ROLE / "tasks" / "main.yml").read_text()) - enabled = next(task for task in tasks if task["name"] == "Configure the enabled macOS runner") - entries = {task["name"]: task for task in enabled["block"]} - self.assertIn("not ansible_check_mode or github_runner_macos_actions_runner_account.rc == 0", entries["Check the installed runner version"]["when"]) - for name in ("Download the pinned Apple Silicon runner package", "Install the pinned Apple Silicon runner package", "Configure the macOS runner"): - self.assertIn("not ansible_check_mode", entries[name]["when"]) - - def test_launchd_restart_listeners_are_real_handlers(self) -> None: + def setUp(self) -> None: + self.tasks = yaml.safe_load((ROLE / "tasks" / "main.yml").read_text()) + self.entries = {task["name"]: task for task in self.tasks} + enabled = self.entries["Configure the enabled macOS runner"] + self.install = {task["name"]: task for task in enabled["block"]} + + def test_existing_destination_and_registration_are_refused_before_install(self) -> None: + names = list(self.entries) + for name in ("Refuse any existing runner path", "Refuse an existing runner registration"): + self.assertLess(names.index(name), names.index("Configure the enabled macOS runner")) + self.assertIn("not github_runner_macos_actions_runner_directory.stat.exists", str(self.entries["Refuse any existing runner path"])) + self.assertNotIn("--replace", self.install["Configure the macOS runner"]["ansible.builtin.command"]["argv"]) + + def test_busy_or_unknown_service_and_disable_fail_before_changes(self) -> None: + self.assertIn("service.rc in [0, 113]", str(self.entries["Refuse unknown launchd service state"])) + self.assertIn("service.rc == 113", str(self.entries["Refuse an existing launchd service"])) + disabled = str(self.entries["Refuse disabling an existing runner"]) + self.assertIn("directory.stat.exists", disabled) + self.assertIn("registration.stat.exists", disabled) + self.assertIn("service.rc == 113", disabled) + self.assertNotIn("bootout", str(self.tasks)) + handlers = yaml.safe_load((ROLE / "handlers" / "main.yml").read_text()) + self.assertNotIn("bootout", str(handlers)) + + def test_first_install_check_mode_skips_staging(self) -> None: + stage = self.install["Stage and publish the first runner installation"] + self.assertEqual(stage["when"], "not ansible_check_mode") + self.assertIn("not ansible_check_mode", self.install["Configure the macOS runner"]["when"]) + + def test_launchd_start_is_a_real_handler(self) -> None: handlers = yaml.safe_load((ROLE / "handlers" / "main.yml").read_text()) listeners = [handler for handler in handlers if handler.get("listen") == "Restart macOS Actions runner"] - self.assertEqual(len(listeners), 2) - self.assertTrue(all("ansible.builtin.command" in handler for handler in listeners)) + self.assertEqual(len(listeners), 1) + self.assertIn("ansible.builtin.command", listeners[0]) if __name__ == "__main__": diff --git a/tests/unit/test_macos_runner_promotion.py b/tests/unit/test_macos_runner_promotion.py new file mode 100644 index 00000000..9f16a610 --- /dev/null +++ b/tests/unit/test_macos_runner_promotion.py @@ -0,0 +1,80 @@ +"""Exercise Darwin exclusive runner promotion on disposable local directories.""" + +import concurrent.futures +import importlib.util +from pathlib import Path +import sys +import tempfile +import unittest + +ROLE = Path(__file__).resolve().parents[2] / "roles" / "github_runner_macos_actions_runner" +SPEC = importlib.util.spec_from_file_location("runner_promotion", ROLE / "files" / "promote.py") +assert SPEC is not None and SPEC.loader is not None +PROMOTION = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(PROMOTION) + + +@unittest.skipUnless(sys.platform == "darwin", "Darwin renamex_np is required") +class PromotionTest(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix="runner-promotion-test-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.destination = self.root / "runner" + + def stage(self, name: str) -> Path: + directory = self.root / name + directory.mkdir() + (directory / "content").write_text(name) + return directory + + def test_absent_destination_is_installed(self) -> None: + source = self.stage("first") + PROMOTION.promote(str(source), str(self.destination)) + self.assertEqual((self.destination / "content").read_text(), "first") + self.assertFalse(source.exists()) + + def test_existing_empty_and_nonempty_destinations_are_unchanged(self) -> None: + for existing in (False, True): + with self.subTest(nonempty=existing): + self.destination.mkdir() + if existing: + (self.destination / ".runner").write_text("registered") + source = self.stage("stage") + with self.assertRaises(OSError): + PROMOTION.promote(str(source), str(self.destination)) + self.assertEqual(list(self.destination.iterdir()), [self.destination / ".runner"] if existing else []) + self.assertTrue(source.exists()) + (source / "content").unlink() + source.rmdir() + if existing: + (self.destination / ".runner").unlink() + self.destination.rmdir() + + def test_concurrent_installers_have_one_winner(self) -> None: + stages = [self.stage("first"), self.stage("second")] + + def attempt(source: Path) -> bool: + try: + PROMOTION.promote(str(source), str(self.destination)) + return True + except FileExistsError: + return False + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(attempt, stages)) + self.assertEqual(sum(results), 1) + self.assertIn((self.destination / "content").read_text(), {"first", "second"}) + self.assertEqual(sum(source.exists() for source in stages), 1) + + def test_interrupted_staging_does_not_create_destination(self) -> None: + self.stage("interrupted") + self.assertFalse(self.destination.exists()) + source = self.stage("retry") + PROMOTION.promote(str(source), str(self.destination)) + self.assertEqual((self.destination / "content").read_text(), "retry") + self.assertTrue((self.root / "interrupted" / "content").exists()) + + +if __name__ == "__main__": + unittest.main()