Files
computer-playbook/cli/administration/deploy/runner/command.py
kevinveenbirkenbachandClaude Opus 4.8 cd4d0e3e28 refactor(cli): move cli/deploy/runner under cli/administration/deploy
The runner command joins its siblings (dedicated, development) under
cli/administration/deploy; cli/deploy is gone. Makefile, docs and the
requirement links now point at the new path, PROJECT_ROOT climbs one
level further, and the unit tests moved along.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 05:20:54 +02:00

236 lines
6.8 KiB
Python

from __future__ import annotations
import argparse
import contextlib
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from . import PROJECT_ROOT
DEFAULT_OUTPUT = str(Path(tempfile.gettempdir()) / "infinito-runner-deploy.log")
DEFAULT_RUNNER_COUNT: int | None = None
RUNNER_ROLE = "svc-runner"
def _normalize_roles(raw_roles: list[str]) -> list[str]:
result: list[str] = []
for item in raw_roles:
parts = [p.strip() for p in item.split(",") if p.strip()]
result.extend(parts)
seen: set[str] = set()
unique: list[str] = []
for role in result:
if role not in seen:
seen.add(role)
unique.append(role)
return unique
def _prepend_runner_role(roles: list[str]) -> list[str]:
filtered = [r for r in roles if r != RUNNER_ROLE]
return [RUNNER_ROLE, *filtered]
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="infinito deploy runner",
description=(
"Deploy an Infinito.Nexus GitHub Actions self-hosted CI runner to a dedicated server.\n\n"
"The svc-runner role is always prepended automatically; additional roles\n"
"passed via --roles are deployed on top of it in the order given."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"hostname",
help="Target server hostname or IP address that will host the runner.",
)
parser.add_argument(
"--port",
type=int,
default=None,
help="SSH port for the target host (default: Ansible default, typically 22).",
)
parser.add_argument(
"--roles",
required=True,
nargs="+",
dest="roles",
help=(
"Roles to deploy onto the runner (space- or comma-separated). "
"svc-runner is always prepended automatically."
),
)
parser.add_argument(
"--distribution",
required=True,
help=(
"Target OS distribution of the runner (e.g. debian, archlinux). "
"Selects distro-specific installation tasks inside svc-runner."
),
)
parser.add_argument(
"--output",
default=DEFAULT_OUTPUT,
help=(
f"File path the deploy stdout/stderr stream is written to "
f"(default: {DEFAULT_OUTPUT})."
),
)
parser.add_argument(
"--runner-count",
type=int,
default=None,
dest="runner_count",
metavar="N",
help=(
"Number of parallel runner instances to provision on the target host "
"(default: auto-computed from server vCPUs by the role). "
"Each instance handles one CI job at a time."
),
)
parser.add_argument(
"--owner",
default=None,
help=(
"GitHub repository owner the runner registers with "
"(default: role default 'infinito-nexus'). "
"Override to your GitHub username when deploying for a fork."
),
)
parser.add_argument(
"--repo",
default=None,
help=(
"GitHub repository name the runner registers with "
"(default: role default 'core'). "
"Override when deploying for a different repository."
),
)
return parser
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
def _build_inventory(hostname: str, port: int | None) -> str:
host_line = hostname
if hostname in _LOCAL_HOSTS:
host_line += (
f" ansible_connection=local ansible_python_interpreter={sys.executable}"
)
elif port is not None:
host_line += f" ansible_port={port}"
return f"[runners]\n{host_line}\n"
def _build_playbook(roles: list[str]) -> str:
role_entries = "\n".join(f" - {r}" for r in roles)
return f"---\n- hosts: runners\n become: true\n roles:\n{role_entries}\n"
def _run_deploy(
*,
hostname: str,
port: int | None,
roles: list[str],
distribution: str,
output_file: str,
runner_count: int,
owner: str | None,
repo: str | None,
) -> int:
inventory_content = _build_inventory(hostname, port)
playbook_content = _build_playbook(roles)
inv_fd, inv_path = tempfile.mkstemp(
suffix=".ini", prefix="infinito-runner-inv-", dir=str(PROJECT_ROOT)
)
pb_fd, pb_path = tempfile.mkstemp(
suffix=".yml", prefix="infinito-runner-pb-", dir=str(PROJECT_ROOT)
)
try:
with os.fdopen(inv_fd, "w") as f:
f.write(inventory_content)
with os.fdopen(pb_fd, "w") as f:
f.write(playbook_content)
cmd = [
"ansible-playbook",
"-i",
inv_path,
pb_path,
"-e",
f"RUNNER_DISTRIBUTION={distribution}",
"-e",
"MASK_CREDENTIALS_IN_LOGS=true",
]
if runner_count is not None:
cmd += ["-e", f"RUNNER_COUNT={runner_count}"]
if owner is not None:
cmd += ["-e", f"RUNNER_GITHUB_OWNER={owner}"]
if repo is not None:
cmd += ["-e", f"RUNNER_GITHUB_REPO={repo}"]
extra_env = dict(os.environ)
token = os.environ.get("RUNNER_API_TOKEN") or os.environ.get("GH_TOKEN") or ""
if token:
extra_env["GH_TOKEN"] = token
extra_env["RUNNER_API_TOKEN"] = token
print(f"\n▶️ Deploying runner to {hostname} — output: {output_file}\n")
with Path(output_file).open("w", encoding="utf-8") as out_f:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
cwd=str(PROJECT_ROOT),
env=extra_env,
)
for line in proc.stdout:
print(line, end="", flush=True)
out_f.write(line)
proc.wait()
if proc.returncode != 0:
print(
f"\n[ERROR] Deploy failed with exit code {proc.returncode}\n",
file=sys.stderr,
)
return proc.returncode
finally:
with contextlib.suppress(OSError):
Path(inv_path).unlink()
with contextlib.suppress(OSError):
Path(pb_path).unlink()
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
roles = _normalize_roles(args.roles)
roles = _prepend_runner_role(roles)
return _run_deploy(
hostname=args.hostname,
port=args.port,
roles=roles,
distribution=args.distribution,
output_file=args.output,
runner_count=args.runner_count,
owner=args.owner,
repo=args.repo,
)