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, )