From 58807ebc29cbd3c3e078fa8fc645262be96f7d65 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 20 Oct 2026 21:54:25 +0100 Subject: [PATCH] fix(cluster): report a first run's Compose start under --check A first run's dry run creates no project directory, and the Compose module fails on a project directory that does not exist, so a dry run of a cluster's first deploy on a host, such as a second cluster beside the first, stopped at the start of its first server. The role now reads whether the directory exists before each server's and agent's start and, under --check with no directory yet, reports the start it would make instead of asking Compose. Fixes #122 --- roles/github_runner_cluster/tasks/main.yml | 18 ++++++++++++ .../tasks/start_server.yml | 18 ++++++++++++ tests/unit/cluster_stand_ins.py | 2 ++ tests/unit/test_server_recreate.py | 28 ++++++++++++++++--- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/roles/github_runner_cluster/tasks/main.yml b/roles/github_runner_cluster/tasks/main.yml index d99fee7..4ea58aa 100644 --- a/roles/github_runner_cluster/tasks/main.yml +++ b/roles/github_runner_cluster/tasks/main.yml @@ -138,7 +138,25 @@ mode: "0600" no_log: true + - name: Read whether the agent's Compose project directory exists + # Under --check on a first run the directory was not created above, and Compose cannot read a project directory that does not exist. + ansible.builtin.stat: + path: "{{ github_runner_cluster_dir }}" + register: github_runner_cluster_dir_state + check_mode: false + + - name: Report that the agent would be started in a project directory this run creates + ansible.builtin.debug: + msg: >- + Would create {{ github_runner_cluster_dir }} and start the k3s agent there, as {{ github_runner_cluster_node_container }} + in the Compose project {{ github_runner_cluster_compose_project }}. + changed_when: true + when: + - ansible_check_mode + - not github_runner_cluster_dir_state.stat.exists + - name: Join the cluster as an agent + when: not (ansible_check_mode and not github_runner_cluster_dir_state.stat.exists) community.docker.docker_compose_v2: project_src: "{{ github_runner_cluster_dir }}" project_name: "{{ github_runner_cluster_compose_project }}" diff --git a/roles/github_runner_cluster/tasks/start_server.yml b/roles/github_runner_cluster/tasks/start_server.yml index 0c57fca..4e43d4e 100644 --- a/roles/github_runner_cluster/tasks/start_server.yml +++ b/roles/github_runner_cluster/tasks/start_server.yml @@ -4,7 +4,25 @@ - name: Check whether Compose would recreate this server's container, and refuse if that would take quorum away ansible.builtin.include_tasks: server_recreate_check.yml +- name: Read whether this server's Compose project directory exists + # Under --check on a first run the role has created no directory, and Compose cannot read a project directory that does not exist, so the start below is skipped and reported instead. + ansible.builtin.stat: + path: "{{ github_runner_cluster_dir }}" + register: github_runner_cluster_dir_state + check_mode: false + +- name: Report that the server would be started in a project directory this run creates + ansible.builtin.debug: + msg: >- + Would create {{ github_runner_cluster_dir }} and start the k3s server there, as {{ github_runner_cluster_node_container }} + in the Compose project {{ github_runner_cluster_compose_project }}. + changed_when: true + when: + - ansible_check_mode + - not github_runner_cluster_dir_state.stat.exists + - name: Start k3s via docker compose (bootstrapping or joining, per this host's own .env) + when: not (ansible_check_mode and not github_runner_cluster_dir_state.stat.exists) community.docker.docker_compose_v2: project_src: "{{ github_runner_cluster_dir }}" project_name: "{{ github_runner_cluster_compose_project }}" diff --git a/tests/unit/cluster_stand_ins.py b/tests/unit/cluster_stand_ins.py index a2ee1fe..aa19586 100644 --- a/tests/unit/cluster_stand_ins.py +++ b/tests/unit/cluster_stand_ins.py @@ -282,6 +282,8 @@ def hosts(self, servers: tuple[str, ...], agents: tuple[str, ...] = (), **extra: hosts: dict[str, Any] = {} for name in (*servers, *agents): server = name in servers + # Every earlier run of the role created the node's Compose project directory, which the role reads before starting Compose under --check. + (self.work / "nodes" / name).mkdir(parents=True, exist_ok=True) hosts[name] = { "github_runner_cluster_effective_node_role": "server" if server else "agent", "github_runner_cluster_effective_node_name": name, diff --git a/tests/unit/test_server_recreate.py b/tests/unit/test_server_recreate.py index 3972117..56179e6 100644 --- a/tests/unit/test_server_recreate.py +++ b/tests/unit/test_server_recreate.py @@ -426,6 +426,26 @@ def test_check_mode_reports_every_recreate_and_changes_nothing(self) -> None: self.assertEqual([call for call in self.calls() if " kubectl get lease " in call], []) self.assertEqual((self.stub / "cluster.nodes").read_text().split(), ["node1", "True", "node2", "True", "node3", "True"]) + def test_check_mode_on_a_first_run_reports_the_start_it_cannot_ask_compose_about(self) -> None: + # A first run's dry run creates no project directory, and Compose cannot read a project directory that does not exist. + hosts, group_vars = self.hosts(("node1", "node2", "node3")) + for name in hosts: + (self.work / "nodes" / name).rmdir() + result = self.run_tasks("start_servers", hosts, group_vars, check=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + for n in (1, 2, 3): + self.assertIn(f"Would create {self.work / 'nodes' / f'node{n}'} and start the k3s server there, as node{n}-k3s", result.stdout.replace("\n", " ")) + self.assertEqual([call for call in self.calls() if call.startswith("module up")], []) + self.assertFalse((self.work / "nodes").joinpath("node1").exists()) + + def test_check_mode_asks_compose_once_the_project_directory_exists(self) -> None: + for n in (1, 2, 3): + self.rendered(f"node{n}", HASH_NEW) + result = self.start(check=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertCountEqual([call for call in self.calls() if call.startswith("module up")], [f"module up node{n} profiles=server check=True" for n in (1, 2, 3)]) + self.assertNotIn("Would create", result.stdout) + def test_check_mode_refuses_what_a_run_would_refuse(self) -> None: self.running_cluster({"node1": HASH_OLD, "node2": HASH_NEW, "node3": HASH_NEW}) self.nodes(node1="True", node2="False", node3="True") @@ -468,10 +488,10 @@ def test_servers_start_through_their_turns_and_agents_do_not(self) -> None: def test_a_turn_checks_before_it_starts_and_waits_only_afterwards(self) -> None: tasks = role_tasks("start_server.yml") - order = [task.get("ansible.builtin.include_tasks") or next(key for key in task if key.startswith("community.docker")) for task in tasks] - self.assertEqual(order, ["server_recreate_check.yml", "community.docker.docker_compose_v2", "server_ready.yml"]) - self.assertEqual(tasks[1]["community.docker.docker_compose_v2"]["profiles"], ["server"]) - self.assertIn("not ansible_check_mode", tasks[2]["when"]) + order = [task.get("ansible.builtin.include_tasks") or next(key for key in task if key.startswith(("community.docker", "ansible.builtin."))) for task in tasks] + self.assertEqual(order, ["server_recreate_check.yml", "ansible.builtin.stat", "ansible.builtin.debug", "community.docker.docker_compose_v2", "server_ready.yml"]) + self.assertEqual(tasks[3]["community.docker.docker_compose_v2"]["profiles"], ["server"]) + self.assertIn("not ansible_check_mode", tasks[4]["when"]) def test_the_turns_loop_over_the_servers_in_the_play_and_run_for_one_host_each(self) -> None: (task,) = role_tasks("start_servers.yml")