Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions roles/github_runner_macos_actions_runner/files/promote.py
Original file line number Diff line number Diff line change
@@ -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])
8 changes: 0 additions & 8 deletions roles/github_runner_macos_actions_runner/handlers/main.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
182 changes: 107 additions & 75 deletions roles/github_runner_macos_actions_runner/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
45 changes: 33 additions & 12 deletions tests/unit/test_macos_actions_runner_check.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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__":
Expand Down
80 changes: 80 additions & 0 deletions tests/unit/test_macos_runner_promotion.py
Original file line number Diff line number Diff line change
@@ -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()
Loading