Skip to content

Commit 6fbc35e

Browse files
committed
test(cluster): cover the recorded Docker role and its Lease guard
The stand-ins, the env rendering tests and the role tests follow the single recorded role file.
1 parent b6da3c5 commit 6fbc35e

18 files changed

Lines changed: 454 additions & 97 deletions

‎tests/cluster_versions/render_env.yml‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
2-
# Renders the cluster role's env.j2 and env-agent.j2 with the role's defaults and placeholder values for the settings this test does not look at, writing them to env_output and env_agent_output, and, when compose_files_output is set, the Compose file list the node's project would use, for tests/unit/test_cluster_versions.py, test_cluster_etcd_timing.py and test_cluster_instance.py. Role variables are passed as extra variables, which outrank the defaults loaded here.
3-
- name: Render the node .env templates
2+
# Renders the cluster role's env.j2, the one .env a Docker node gets whichever role it runs, with the role's defaults and placeholder values for the settings this test does not look at, writing it to env_output, and, when compose_files_output is set, the Compose file list the node's project would use, for tests/unit/test_cluster_versions.py, test_cluster_etcd_timing.py, test_cluster_instance.py, test_cluster_voters.py and test_docker_role.py. Role variables are passed as extra variables, which outrank the defaults loaded here.
3+
- name: Render the node .env
44
hosts: localhost
55
connection: local
66
gather_facts: false
@@ -22,12 +22,6 @@
2222
dest: "{{ env_output }}"
2323
mode: "0600"
2424

25-
- name: Render env-agent.j2
26-
ansible.builtin.copy:
27-
content: "{{ lookup('ansible.builtin.template', playbook_dir ~ '/../../roles/github_runner_cluster/templates/env-agent.j2') }}"
28-
dest: "{{ env_agent_output }}"
29-
mode: "0600"
30-
3125
- name: Write the Compose files the node's project would use
3226
ansible.builtin.copy:
3327
content: "{{ github_runner_cluster_compose_file_list | to_json }}"

‎tests/host_network/play.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
vars:
88
github_runner_cluster_docker_cli_environment: {}
99
github_runner_cluster_container_name: github-runner-k3s
10+
github_runner_cluster_agent_container_name: github-runner-k3s-agent
1011
github_runner_cluster_compose_project: github-runner
1112
github_runner_cluster_network_probe_image: "busybox:1.37"
1213
github_runner_cluster_cluster_read_timeout: 30

‎tests/regrade/render_env.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
vars:
1010
github_runner_cluster_effective_node_name: node2
1111
github_runner_cluster_effective_node_role: server
12+
github_runner_cluster_docker_role: server
1213
github_runner_cluster_topology:
1314
servers: [node1, node2, node3]
1415
hosts:

‎tests/regrade/service.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
gather_facts: false
77
vars:
88
github_runner_cluster_effective_node_role: server
9+
github_runner_cluster_docker_role: server
910
github_runner_cluster_dir: /srv/github-runner
1011
github_runner_cluster_docker_cli_context: ""
1112
github_runner_cluster_docker_cli_environment: {}

‎tests/unit/cluster_stand_ins.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def flatten(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]:
5454
return found
5555

5656

57-
# Stands in for the docker CLI, with its state in files under $DOCKER_STUB: containers/<name>.state (what docker ps prints), .image, .hash (the Compose configuration hash label), .node/ (the files of the container's /etc/rancher/node), .volume (the volume mounted there) and .cp-fails; volumes/<name>/ and labels/<name>; rendered/<project>.txt (what docker compose config --hash prints); cluster.nodes (what kubectl get nodes prints, "<node> <Ready status>" per line); and containers/<name>.kubectl-fails and never-ready-<node>; leases/<node> counts reads of a node's lease, whose renewal is older than its container's start on the first read and later after that, which is also when the node reports Ready (kubelet confirming itself) unless never-ready-<node> exists. Every call is appended to calls.log with its DOCKER_CONTEXT, and the owner and mode of each file docker cp extracts to owners.log. docker cp moves a real tar stream between processes, so a pipeline that is wired wrongly moves nothing.
57+
# Stands in for the docker CLI, with its state in files under $DOCKER_STUB: containers/<name>.state (what docker ps prints), .image, .hash (the Compose configuration hash label), .node/ (the files of the container's /etc/rancher/node), .volume (the volume mounted there) and .cp-fails; volumes/<name>/ and labels/<name>; rendered/<project>.txt (what docker compose config --hash prints); cluster.nodes (what kubectl get nodes prints, "<node> <Ready status>" per line); fleet-regrade.lease (what kubectl get lease fleet-regrade prints, nothing when absent); and containers/<name>.kubectl-fails and never-ready-<node>; leases/<node> counts reads of a node's lease, whose renewal is older than its container's start on the first read and later after that, which is also when the node reports Ready (kubelet confirming itself) unless never-ready-<node> exists. Every call is appended to calls.log with its DOCKER_CONTEXT, and the owner and mode of each file docker cp extracts to owners.log. docker cp moves a real tar stream between processes, so a pipeline that is wired wrongly moves nothing.
5858
DOCKER_STUB = """#!{python}
5959
import os, re, sys, tarfile
6060
stub = os.environ["DOCKER_STUB"]
@@ -176,7 +176,9 @@ def set_node(node, status):
176176
if rest[:2] == ["kubectl", "get"]:
177177
if read("containers", container + ".kubectl-fails") is not None:
178178
die("The connection to the server 127.0.0.1:6443 was refused")
179-
if rest[2] == "nodes":
179+
if rest[2:4] == ["lease", "fleet-regrade"]:
180+
print(read("fleet-regrade.lease") or "", end="")
181+
elif rest[2] == "nodes":
180182
print(read("cluster.nodes") or "", end="")
181183
elif rest[2] == "node":
182184
print(dict(line.split() for line in (read("cluster.nodes") or "").splitlines() if len(line.split()) == 2).get(rest[3], ""), end="")
@@ -288,9 +290,9 @@ def tearDown(self) -> None:
288290
# Whatever a test did, no real tool was reached.
289291
self.assertFalse((self.stub / "forbidden.log").exists(), (self.stub / "forbidden.log").read_text() if (self.stub / "forbidden.log").exists() else "")
290292

291-
def hosts(self, servers: tuple[str, ...], agents: tuple[str, ...] = (), **extra: Any) -> tuple[dict[str, Any], dict[str, Any]]:
292-
"""The inventory's hosts and the group variables for a cluster whose servers and agents are named, each host's container named for it."""
293-
topology = {"servers": list(servers), "hosts": {name: {"role": "server" if name in servers else "agent", "bootstrap": name == servers[0], "node_name": name} for name in (*servers, *agents)}}
293+
def hosts(self, servers: tuple[str, ...], agents: tuple[str, ...] = (), eligible: tuple[str, ...] = (), **extra: Any) -> tuple[dict[str, Any], dict[str, Any]]:
294+
"""The inventory's hosts and the group variables for a cluster whose servers and agents are named, those in ``eligible`` marked github_runner_cluster_voter_eligible, each host's containers named for it."""
295+
topology = {"servers": list(servers), "hosts": {name: {"role": "server" if name in servers else "agent", "bootstrap": name == servers[0], "node_name": name, "address": name, "native": False, "eligible": name in eligible} for name in (*servers, *agents)}}
294296
hosts: dict[str, Any] = {}
295297
for name in (*servers, *agents):
296298
server = name in servers
@@ -299,8 +301,11 @@ def hosts(self, servers: tuple[str, ...], agents: tuple[str, ...] = (), **extra:
299301
(self.work / "nodes" / name / "docker-compose.yml").write_text("services: {}\n")
300302
hosts[name] = {
301303
"github_runner_cluster_effective_node_role": "server" if server else "agent",
304+
# The role tasks/docker_role.yml decides, which a test that runs it sets again.
305+
"github_runner_cluster_docker_role": "server" if server else "agent",
302306
"github_runner_cluster_effective_node_name": name,
303307
"github_runner_cluster_container_name": f"{name}-k3s",
308+
"github_runner_cluster_agent_container_name": f"{name}-k3s-agent",
304309
"github_runner_cluster_dir": str(self.work / "nodes" / name),
305310
"github_runner_cluster_compose_project": name,
306311
"github_runner_cluster_compose_file_list": ["docker-compose.yml"],

‎tests/unit/native_stand_ins.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@ def make_stand_ins(self) -> None:
213213
"get node "*removed-node-name*) if [ -e "$state/member-removed" ]; then printf 'laptop-0a1b2c3d,'; else printf ',laptop-0a1b2c3d'; fi ;;
214214
"annotate --overwrite node "*"etcd.k3s.cattle.io/remove=true"*) [ -e "$state/member-stays" ] || touch "$state/member-removed" ;;
215215
"get node "*) if [ -e "$state/node-ready" ]; then cat "$state/node-ready"; else printf 'True,True'; fi ;;
216+
# The regrade Lease, as JSON, and nothing when there is none; any other Lease is a node's, whose renew time is asked for.
217+
"get lease fleet-regrade "*) [ ! -e "$state/fleet-regrade-lease" ] || cat "$state/fleet-regrade-lease" ;;
216218
"get lease "*) if [ -e "$state/lease" ]; then cat "$state/lease"; else echo 2099-01-01T00:00:00Z; fi ;;
217219
"get pods "*) [ -e "$state/runner-pods" ] && cat "$state/runner-pods"; exit 0 ;;
218220
cordon*|uncordon*|drain*|label*|annotate*) ;;

‎tests/unit/test_cluster_compose_files_in_use.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ def test_the_files_are_placed_then_the_list_is_worked_out_then_the_mesh_and_ever
142142
self.assertGreater(matching[0], worked_out, later)
143143

144144
def test_no_task_names_the_whole_list_to_a_compose_command_except_where_it_is_defined_or_checked(self) -> None:
145-
allowed = {"defaults/main.yml", "tasks/compose_files.yml", "tasks/compose_files_in_use.yml", "templates/env.j2", "templates/env-agent.j2"}
145+
allowed = {"defaults/main.yml", "tasks/compose_files.yml", "tasks/compose_files_in_use.yml", "templates/env.j2"}
146146
offenders = []
147147
for path in sorted(ROLE.rglob("*")):
148148
relative = str(path.relative_to(ROLE))

‎tests/unit/test_cluster_etcd_timing.py‎

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -107,26 +107,21 @@ def test_an_agent_with_other_timing_is_not_compared_with_the_servers(self) -> No
107107
result = self.check(hosts)
108108
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
109109

110-
def render_env(self, *extra: str) -> tuple[list[str], list[str]]:
111-
env, agent = self.work / "env", self.work / "env-agent"
112-
args = [ANSIBLE_PLAYBOOK, str(ROOT / "tests" / "cluster_versions" / "render_env.yml"), "-e", f"env_output={env}", "-e", f"env_agent_output={agent}", "-e", "github_runner_cluster_dir=/srv/github-runner", *extra]
110+
def render_env(self, *extra: str) -> list[str]:
111+
env = self.work / "env"
112+
args = [ANSIBLE_PLAYBOOK, str(ROOT / "tests" / "cluster_versions" / "render_env.yml"), "-e", f"env_output={env}", "-e", "github_runner_cluster_dir=/srv/github-runner", *extra]
113113
result = subprocess.run(args, capture_output=True, text=True, check=False, env=ansible_env(), cwd=self.work)
114114
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
115-
return env.read_text().splitlines(), agent.read_text().splitlines()
115+
return env.read_text().splitlines()
116116

117-
def test_the_server_env_carries_both_values_when_set(self) -> None:
118-
server, _ = self.render_env("-e", f"{HEARTBEAT}=500", "-e", f"{ELECTION}=5000")
119-
self.assertIn("K3S_ETCD_HEARTBEAT_INTERVAL=500", server)
120-
self.assertIn("K3S_ETCD_ELECTION_TIMEOUT=5000", server)
117+
def test_the_env_carries_both_values_when_set(self) -> None:
118+
# The agent service takes neither from it: see ComposeFileTest.
119+
lines = self.render_env("-e", f"{HEARTBEAT}=500", "-e", f"{ELECTION}=5000")
120+
self.assertIn("K3S_ETCD_HEARTBEAT_INTERVAL=500", lines)
121+
self.assertIn("K3S_ETCD_ELECTION_TIMEOUT=5000", lines)
121122

122-
def test_the_server_env_names_neither_by_default(self) -> None:
123-
server, agent = self.render_env()
124-
for lines in (server, agent):
125-
self.assertEqual([line for line in lines if "ETCD" in line], [])
126-
127-
def test_the_agent_env_never_carries_them(self) -> None:
128-
_, agent = self.render_env("-e", f"{HEARTBEAT}=500", "-e", f"{ELECTION}=5000")
129-
self.assertEqual([line for line in agent if "ETCD" in line], [])
123+
def test_the_env_names_neither_by_default(self) -> None:
124+
self.assertEqual([line for line in self.render_env() if "ETCD" in line], [])
130125

131126

132127
class ComposeFileTest(unittest.TestCase):

‎tests/unit/test_cluster_instance.py‎

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -140,34 +140,32 @@ def setUp(self) -> None:
140140
self.work = Path(tempfile.mkdtemp())
141141
self.addCleanup(shutil.rmtree, self.work)
142142

143-
def render(self, *extra: str) -> tuple[list[str], list[str], list[str]]:
144-
env, agent, files = self.work / "env", self.work / "env-agent", self.work / "files.json"
145-
args = [ANSIBLE_PLAYBOOK, str(ROOT / "tests" / "cluster_versions" / "render_env.yml"), "-e", f"env_output={env}", "-e", f"env_agent_output={agent}", "-e", f"compose_files_output={files}", "-e", "github_runner_cluster_dir=/srv/github-runner", *extra]
143+
def render(self, *extra: str) -> tuple[list[str], list[str]]:
144+
env, files = self.work / "env", self.work / "files.json"
145+
args = [ANSIBLE_PLAYBOOK, str(ROOT / "tests" / "cluster_versions" / "render_env.yml"), "-e", f"env_output={env}", "-e", f"compose_files_output={files}", "-e", "github_runner_cluster_dir=/srv/github-runner", *extra]
146146
result = subprocess.run(args, capture_output=True, text=True, check=False, env=ansible_env(), cwd=self.work)
147147
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
148148
compose_files: list[str] = json.loads(files.read_text())
149-
return env.read_text().splitlines(), agent.read_text().splitlines(), compose_files
149+
return env.read_text().splitlines(), compose_files
150150

151151
def test_the_defaults_write_the_existing_names_and_port_and_add_no_compose_file(self) -> None:
152-
server, agent, files = self.render()
153-
self.assertIn(f"K3S_CONTAINER_NAME={SERVER_CONTAINER}", server)
154-
self.assertIn(f"K3S_API_PORT={K3S_PORT}", server)
155-
self.assertIn(f"K3S_AGENT_CONTAINER_NAME={AGENT_CONTAINER}", agent)
156-
self.assertIn(f"K3S_CONTAINER_NAME={SERVER_CONTAINER}", agent)
157-
self.assertIn(f"K3S_API_PORT={K3S_PORT}", agent)
152+
lines, files = self.render()
153+
self.assertIn(f"K3S_CONTAINER_NAME={SERVER_CONTAINER}", lines)
154+
self.assertIn(f"K3S_AGENT_CONTAINER_NAME={AGENT_CONTAINER}", lines)
155+
self.assertIn(f"K3S_API_PORT={K3S_PORT}", lines)
158156
self.assertEqual(files, ["docker-compose.yml"])
159-
self.assertIn("COMPOSE_FILE=/srv/github-runner/docker-compose.yml", server)
157+
self.assertIn("COMPOSE_FILE=/srv/github-runner/docker-compose.yml", lines)
160158

161-
def test_a_second_cluster_s_values_reach_both_templates_and_the_port_adds_the_override(self) -> None:
162-
server, agent, files = self.render("-e", "github_runner_cluster_container_name=second-k3s", "-e", "github_runner_cluster_agent_container_name=second-k3s-agent", "-e", "github_runner_cluster_api_port=6543")
163-
self.assertIn("K3S_CONTAINER_NAME=second-k3s", server)
164-
self.assertIn("K3S_API_PORT=6543", server)
165-
self.assertIn("K3S_AGENT_CONTAINER_NAME=second-k3s-agent", agent)
159+
def test_a_second_cluster_s_values_reach_the_env_and_the_port_adds_the_override(self) -> None:
160+
lines, files = self.render("-e", "github_runner_cluster_container_name=second-k3s", "-e", "github_runner_cluster_agent_container_name=second-k3s-agent", "-e", "github_runner_cluster_api_port=6543")
161+
self.assertIn("K3S_CONTAINER_NAME=second-k3s", lines)
162+
self.assertIn("K3S_API_PORT=6543", lines)
163+
self.assertIn("K3S_AGENT_CONTAINER_NAME=second-k3s-agent", lines)
166164
self.assertEqual(files, ["docker-compose.yml", "docker-compose.api-port.yml"])
167-
self.assertIn("COMPOSE_FILE=/srv/github-runner/docker-compose.yml:/srv/github-runner/docker-compose.api-port.yml", server)
165+
self.assertIn("COMPOSE_FILE=/srv/github-runner/docker-compose.yml:/srv/github-runner/docker-compose.api-port.yml", lines)
168166

169167
def test_the_default_port_written_as_a_string_adds_no_override(self) -> None:
170-
_, _, files = self.render("-e", '{"github_runner_cluster_api_port": "6443"}')
168+
_, files = self.render("-e", '{"github_runner_cluster_api_port": "6443"}')
171169
self.assertEqual(files, ["docker-compose.yml"])
172170

173171

@@ -319,7 +317,8 @@ def containers(self, *lines: str) -> None:
319317
(self.stub / "containers").write_text("\n".join(lines) + "\n")
320318

321319
def run_check(self, *extra: str, role: str = "server", group_vars: dict[str, Any] | None = None, hosts: dict[str, dict[str, Any]] | None = None) -> subprocess.CompletedProcess[str]:
322-
node = {"github_runner_cluster_effective_node_role": role, "github_runner_cluster_docker_cli_environment": {}, "github_runner_cluster_dir": "/srv/second"}
320+
# The role tasks/docker_role.yml would decide is the inventory's, for a host not marked eligible.
321+
node = {"github_runner_cluster_effective_node_role": role, "github_runner_cluster_docker_role": role, "github_runner_cluster_docker_cli_environment": {}, "github_runner_cluster_dir": "/srv/second"}
323322
group = {"vars": {"github_runner_cluster_compose_project": "second", **(group_vars or {})}, "hosts": {name: node | values for name, values in (hosts or {"node1": {}}).items()}}
324323
inventory = self.work / "inventory.yml"
325324
inventory.write_text(yaml.safe_dump({"all": {"children": {"github_runner_cluster": group}}}))

‎tests/unit/test_cluster_regrade.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ def test_an_agent_is_refused(self) -> None:
131131
result = self.refused("node4 is an agent", {name: {} for name in ("node1", "node2", "node3", "node4")}, **{ENABLED: True})
132132
self.assertEqual(re.findall(r"fatal: \[(node\d)\]", result.stdout), ["node4"])
133133

134+
def test_a_docker_agent_that_may_become_a_server_may_regrade(self) -> None:
135+
# The service runs on such a host only while the role it records is server (tasks/regrade_service.yml), so the setting it keeps across a swap is not a mistake.
136+
hosts = {name: {ENABLED: True} for name in ("node1", "node2", "node3")} | {"node4": {ENABLED: True, "github_runner_cluster_voter_eligible": True}}
137+
result = self.check(hosts)
138+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
139+
134140
def test_regrading_only_the_servers_of_a_group_with_an_agent_passes(self) -> None:
135141
hosts = {"node1": {ENABLED: True}, "node2": {ENABLED: True}, "node3": {ENABLED: True}, "node4": {}}
136142
result = self.check(hosts)
@@ -466,14 +472,16 @@ def test_the_settings_are_checked_before_the_role_changes_anything(self) -> None
466472
self.assertLess(order.index("topology.yml"), order.index("regrade.yml"))
467473
self.assertLess(order.index("regrade.yml"), order.index("Create this host's Compose project directory"))
468474

469-
def test_the_service_runs_on_servers_after_the_node_is_started(self) -> None:
475+
def test_the_service_runs_on_servers_after_the_node_is_started_and_is_removed_from_agents(self) -> None:
470476
tasks = yaml.safe_load((ROLE / "tasks" / "main.yml").read_text())
471-
[block] = [task for task in tasks if "block" in task and task.get("when") == 'github_runner_cluster_effective_node_role == "server"']
477+
[block] = [task for task in tasks if "block" in task and task.get("when") == 'github_runner_cluster_docker_role == "server"']
472478
steps = [step.get("ansible.builtin.include_tasks") for step in block["block"]]
473479
self.assertLess(steps.index("start_servers.yml"), steps.index("regrade_service.yml"))
474480
self.assertLess(steps.index("regrade_service.yml"), steps.index("end_checks.yml"))
475-
[agent] = [task for task in tasks if "block" in task and task.get("when") == 'github_runner_cluster_effective_node_role == "agent"']
476-
self.assertNotIn("regrade_service.yml", [step.get("ansible.builtin.include_tasks") for step in agent["block"]])
481+
# On an agent it only removes the service, which a host that ran as a server before its recorded role changed may still run.
482+
[agent] = [task for task in tasks if "block" in task and task.get("when") == 'github_runner_cluster_docker_role == "agent"']
483+
agent_steps = [step.get("ansible.builtin.include_tasks") for step in agent["block"]]
484+
self.assertLess(agent_steps.index("start_agent.yml"), agent_steps.index("regrade_service.yml"))
477485

478486
def test_the_wait_for_ready_leaves_out_a_server_regrading_has_stepped_down(self) -> None:
479487
text = (ROLE / "tasks" / "wait_for_ready.yml").read_text()

0 commit comments

Comments
 (0)