mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
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>
236 lines
6.8 KiB
Python
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,
|
|
)
|