From 764a1adf3004628ef8a3da41f2b64c8366f7aa64 Mon Sep 17 00:00:00 2001 From: Alejandro Roman Date: Thu, 30 Apr 2026 22:46:18 +0200 Subject: [PATCH] feat(svc-runners): add the logic to have x numbers of runners self hosted ad connected to GIthub, allowing to have 20 runners from gh + x self hosted --- .github/workflows/test-deploy-server.yml | 55 ++++- .github/workflows/test-deploy-universal.yml | 47 +++- .github/workflows/test-deploy-workstation.yml | 47 +++- Makefile | 33 ++- cli/deploy/runner/__init__.py | 0 cli/deploy/runner/__main__.py | 6 + cli/deploy/runner/command.py | 222 ++++++++++++++++++ docs/requirements/014-svc-runner-ci.md | 44 ++-- roles/svc-runner/README.md | 149 ++++++++++++ roles/svc-runner/__init__.py | 0 roles/svc-runner/defaults/main.yml | 8 + roles/svc-runner/meta/main.yml | 33 +++ roles/svc-runner/meta/schema.yml | 1 + roles/svc-runner/meta/services.yml | 2 + roles/svc-runner/tasks/install/archlinux.yml | 11 + roles/svc-runner/tasks/install/debian.yml | 15 ++ roles/svc-runner/tasks/install/ubuntu.yml | 15 ++ roles/svc-runner/tasks/instance.yml | 47 ++++ roles/svc-runner/tasks/main.yml | 48 ++++ scripts/github/split_runner_apps.sh | 43 ++++ tests/unit/cli/deploy/runner/__init__.py | 0 tests/unit/cli/deploy/runner/test_command.py | 195 +++++++++++++++ 22 files changed, 989 insertions(+), 32 deletions(-) create mode 100644 cli/deploy/runner/__init__.py create mode 100644 cli/deploy/runner/__main__.py create mode 100644 cli/deploy/runner/command.py create mode 100644 roles/svc-runner/README.md create mode 100644 roles/svc-runner/__init__.py create mode 100644 roles/svc-runner/defaults/main.yml create mode 100644 roles/svc-runner/meta/main.yml create mode 100644 roles/svc-runner/meta/schema.yml create mode 100644 roles/svc-runner/meta/services.yml create mode 100644 roles/svc-runner/tasks/install/archlinux.yml create mode 100644 roles/svc-runner/tasks/install/debian.yml create mode 100644 roles/svc-runner/tasks/install/ubuntu.yml create mode 100644 roles/svc-runner/tasks/instance.yml create mode 100644 roles/svc-runner/tasks/main.yml create mode 100755 scripts/github/split_runner_apps.sh create mode 100644 tests/unit/cli/deploy/runner/__init__.py create mode 100644 tests/unit/cli/deploy/runner/test_command.py diff --git a/.github/workflows/test-deploy-server.yml b/.github/workflows/test-deploy-server.yml index f05b855c21..96b946c954 100644 --- a/.github/workflows/test-deploy-server.yml +++ b/.github/workflows/test-deploy-server.yml @@ -32,7 +32,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 outputs: - apps: ${{ steps.discover.outputs.apps }} + apps_github: ${{ steps.split.outputs.apps_github }} + apps_self_hosted: ${{ steps.split.outputs.apps_self_hosted }} steps: - uses: actions/checkout@v7 @@ -78,9 +79,17 @@ jobs: INFINITO_WHITELIST: ${{ steps.resolve_wl.outputs.whitelist }} run: ./scripts/github/resolve/output_apps.sh + - name: Split apps between GitHub-hosted and self-hosted runners + id: split + shell: bash + env: + APPS_JSON: ${{ steps.discover.outputs.apps }} + CI_SELF_HOSTED_RUNNER_COUNT: ${{ vars.CI_SELF_HOSTED_RUNNER_COUNT || '0' }} + run: bash scripts/github/split_runner_apps.sh + server: name: 🌐 Deploy ${{ matrix.apps }} - if: needs.discover.outputs.apps != '' && needs.discover.outputs.apps != '[]' + if: needs.discover.outputs.apps_github != '' && needs.discover.outputs.apps_github != '[]' runs-on: ubuntu-latest timeout-minutes: 360 needs: discover @@ -88,7 +97,7 @@ jobs: strategy: fail-fast: false matrix: - apps: ${{ fromJson(needs.discover.outputs.apps) }} + apps: ${{ fromJson(needs.discover.outputs.apps_github) }} steps: # Reclaim runner space before Docker pulls start. Server deploy tests pull @@ -159,3 +168,43 @@ jobs: name: playwright-server-${{ matrix.apps }} path: /tmp/playwright-artifacts/ if-no-files-found: ignore + + server-self-hosted: + name: 🌐 Deploy ${{ matrix.apps }} (self-hosted) + if: needs.discover.outputs.apps_self_hosted != '' && needs.discover.outputs.apps_self_hosted != '[]' + runs-on: [self-hosted, linux] + timeout-minutes: 360 + needs: discover + + strategy: + fail-fast: false + matrix: + apps: ${{ fromJson(needs.discover.outputs.apps_self_hosted) }} + + steps: + - uses: actions/checkout@v6 + + - name: Resolve repository owner + shell: bash + run: | + set -euo pipefail + echo "GITHUB_REPOSITORY_OWNER=$(scripts/meta/resolve/repository/owner.sh)" >> "$GITHUB_ENV" + + - name: Deploy app + shell: bash + env: + APPS: ${{ matrix.apps }} + run: ./scripts/tests/deploy/ci/all.sh + + - name: Dump runner state (on failure) + if: failure() + shell: bash + run: ./scripts/github/dump_runner_state.sh "post-deploy failure (${{ matrix.apps }})" + + - name: Upload Playwright artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: playwright-server-self-hosted-${{ matrix.apps }} + path: /tmp/playwright-artifacts/ + if-no-files-found: ignore diff --git a/.github/workflows/test-deploy-universal.yml b/.github/workflows/test-deploy-universal.yml index fb1082409f..7d8c308140 100644 --- a/.github/workflows/test-deploy-universal.yml +++ b/.github/workflows/test-deploy-universal.yml @@ -32,7 +32,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 outputs: - apps: ${{ steps.discover.outputs.apps }} + apps_github: ${{ steps.split.outputs.apps_github }} + apps_self_hosted: ${{ steps.split.outputs.apps_self_hosted }} steps: - uses: actions/checkout@v7 @@ -74,9 +75,17 @@ jobs: INFINITO_WHITELIST: ${{ steps.resolve_wl.outputs.whitelist }} run: ./scripts/github/resolve/output_apps.sh + - name: Split apps between GitHub-hosted and self-hosted runners + id: split + shell: bash + env: + APPS_JSON: ${{ steps.discover.outputs.apps }} + CI_SELF_HOSTED_RUNNER_COUNT: ${{ vars.CI_SELF_HOSTED_RUNNER_COUNT || '0' }} + run: bash scripts/github/split_runner_apps.sh + universal: name: ✨ Deploy ${{ matrix.apps }} - if: needs.discover.outputs.apps != '' && needs.discover.outputs.apps != '[]' + if: needs.discover.outputs.apps_github != '' && needs.discover.outputs.apps_github != '[]' runs-on: ubuntu-latest timeout-minutes: 360 needs: discover @@ -84,7 +93,7 @@ jobs: strategy: fail-fast: false matrix: - apps: ${{ fromJson(needs.discover.outputs.apps) }} + apps: ${{ fromJson(needs.discover.outputs.apps_github) }} steps: # Reclaim runner space before Docker pulls start. Deploy jobs otherwise @@ -136,3 +145,35 @@ jobs: if: failure() shell: bash run: ./scripts/github/runner/dump_state.sh "post-deploy failure (${{ matrix.apps }})" + + universal-self-hosted: + name: ✨ Deploy ${{ matrix.apps }} (self-hosted) + if: needs.discover.outputs.apps_self_hosted != '' && needs.discover.outputs.apps_self_hosted != '[]' + runs-on: [self-hosted, linux] + timeout-minutes: 360 + needs: discover + + strategy: + fail-fast: false + matrix: + apps: ${{ fromJson(needs.discover.outputs.apps_self_hosted) }} + + steps: + - uses: actions/checkout@v6 + + - name: Resolve repository owner + shell: bash + run: | + set -euo pipefail + echo "GITHUB_REPOSITORY_OWNER=$(scripts/meta/resolve/repository/owner.sh)" >> "$GITHUB_ENV" + + - name: Deploy app + shell: bash + env: + APPS: ${{ matrix.apps }} + run: ./scripts/tests/deploy/ci/all.sh + + - name: Dump runner state (on failure) + if: failure() + shell: bash + run: ./scripts/github/runner/dump_state.sh "post-deploy failure (${{ matrix.apps }})" diff --git a/.github/workflows/test-deploy-workstation.yml b/.github/workflows/test-deploy-workstation.yml index df77a00579..4cc6ab51a9 100644 --- a/.github/workflows/test-deploy-workstation.yml +++ b/.github/workflows/test-deploy-workstation.yml @@ -32,7 +32,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 outputs: - apps: ${{ steps.discover.outputs.apps }} + apps_github: ${{ steps.split.outputs.apps_github }} + apps_self_hosted: ${{ steps.split.outputs.apps_self_hosted }} steps: - uses: actions/checkout@v7 @@ -74,9 +75,17 @@ jobs: INFINITO_WHITELIST: ${{ steps.resolve_wl.outputs.whitelist }} run: ./scripts/github/resolve/output_apps.sh + - name: Split apps between GitHub-hosted and self-hosted runners + id: split + shell: bash + env: + APPS_JSON: ${{ steps.discover.outputs.apps }} + CI_SELF_HOSTED_RUNNER_COUNT: ${{ vars.CI_SELF_HOSTED_RUNNER_COUNT || '0' }} + run: bash scripts/github/split_runner_apps.sh + workstation: name: šŸ–„ļø Deploy ${{ matrix.apps }} - if: needs.discover.outputs.apps != '' && needs.discover.outputs.apps != '[]' + if: needs.discover.outputs.apps_github != '' && needs.discover.outputs.apps_github != '[]' runs-on: ubuntu-latest timeout-minutes: 360 needs: discover @@ -84,7 +93,7 @@ jobs: strategy: fail-fast: false matrix: - apps: ${{ fromJson(needs.discover.outputs.apps) }} + apps: ${{ fromJson(needs.discover.outputs.apps_github) }} steps: # Reclaim runner space before Docker pulls start. Deploy jobs otherwise @@ -136,3 +145,35 @@ jobs: if: failure() shell: bash run: ./scripts/github/runner/dump_state.sh "post-deploy failure (${{ matrix.apps }})" + + workstation-self-hosted: + name: šŸ–„ļø Deploy ${{ matrix.apps }} (self-hosted) + if: needs.discover.outputs.apps_self_hosted != '' && needs.discover.outputs.apps_self_hosted != '[]' + runs-on: [self-hosted, linux] + timeout-minutes: 360 + needs: discover + + strategy: + fail-fast: false + matrix: + apps: ${{ fromJson(needs.discover.outputs.apps_self_hosted) }} + + steps: + - uses: actions/checkout@v6 + + - name: Resolve repository owner + shell: bash + run: | + set -euo pipefail + echo "GITHUB_REPOSITORY_OWNER=$(scripts/meta/resolve/repository/owner.sh)" >> "$GITHUB_ENV" + + - name: Deploy app + shell: bash + env: + APPS: ${{ matrix.apps }} + run: ./scripts/tests/deploy/ci/all.sh + + - name: Dump runner state (on failure) + if: failure() + shell: bash + run: ./scripts/github/runner/dump_state.sh "post-deploy failure (${{ matrix.apps }})" diff --git a/Makefile b/Makefile index ed8e6095ba..e4948936c1 100644 --- a/Makefile +++ b/Makefile @@ -538,4 +538,35 @@ wsl2-systemd-check: .PHONY: wsl2-trust-windows # Trust Windows certificates in WSL2. wsl2-trust-windows: - @bash scripts/system/tls/trust/wsl2.sh \ No newline at end of file + @bash scripts/system/tls/trust/wsl2.sh + +.PHONY: runner-ci-deploy +# Provision self-hosted CI runner instances on a remote host. +# Usage: make runner-ci-deploy HOST=runner.example.com DISTRO=debian [COUNT=15] [PORT=22] [OWNER=myuser] [REPO=infinito-nexus] +runner-ci-deploy: + @: "$${HOST:?HOST must be set (e.g. make runner-ci-deploy HOST=runner.example.com DISTRO=debian)}" + @: "$${DISTRO:?DISTRO must be set (e.g. debian, archlinux)}" + @"$${PYTHON}" -m cli.deploy.runner "$${HOST}" \ + --roles svc-runner \ + --distribution "$${DISTRO}" \ + --runner-count "$${COUNT:-15}" \ + $${PORT:+--port "$${PORT}"} \ + $${OWNER:+--owner "$${OWNER}"} \ + $${REPO:+--repo "$${REPO}"} + +.PHONY: runner-ci-enable +# Enable self-hosted CI runners by setting the CI_SELF_HOSTED_RUNNER_COUNT repo variable. +# Usage: make runner-ci-enable COUNT=15 [OWNER=myuser] [REPO=infinito-nexus] +runner-ci-enable: + @: "$${COUNT:?COUNT must be set (e.g. make runner-ci-enable COUNT=15)}" + @gh variable set CI_SELF_HOSTED_RUNNER_COUNT --body "$${COUNT}" \ + $${OWNER:+--repo "$${OWNER}/$${REPO:-infinito-nexus}"} + @echo "Self-hosted runners enabled (count=$${COUNT}). CI will split deploy jobs proportionally across GitHub-hosted and self-hosted runners." + +.PHONY: runner-ci-disable +# Disable self-hosted CI runners — routes all deploy jobs back to GitHub-hosted runners. +# Usage: make runner-ci-disable [OWNER=myuser] [REPO=infinito-nexus] +runner-ci-disable: + @gh variable set CI_SELF_HOSTED_RUNNER_COUNT --body "0" \ + $${OWNER:+--repo "$${OWNER}/$${REPO:-infinito-nexus}"} + @echo "Self-hosted runners disabled. All CI deploy jobs routed to GitHub-hosted runners." diff --git a/cli/deploy/runner/__init__.py b/cli/deploy/runner/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cli/deploy/runner/__main__.py b/cli/deploy/runner/__main__.py new file mode 100644 index 0000000000..32f6bc12c4 --- /dev/null +++ b/cli/deploy/runner/__main__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .command import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cli/deploy/runner/command.py b/cli/deploy/runner/command.py new file mode 100644 index 0000000000..8503232280 --- /dev/null +++ b/cli/deploy/runner/command.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import List, Optional + +DEFAULT_OUTPUT = "/tmp/infinito-runner-deploy.log" +DEFAULT_RUNNER_COUNT = 15 +RUNNER_ROLE = "svc-runner" + + +def _normalize_roles(raw_roles: List[str]) -> List[str]: + """Normalize roles: accept space- and comma-separated values, deduplicate, preserve order.""" + 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]: + """Ensure svc-runner is the first entry in the roles list.""" + 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=DEFAULT_RUNNER_COUNT, + dest="runner_count", + metavar="N", + help=( + f"Number of parallel runner instances to provision on the target host " + f"(default: {DEFAULT_RUNNER_COUNT}). 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 'kevinveenbirkenbach'). " + "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 'infinito-nexus'). " + "Override when deploying for a different repository." + ), + ) + + return parser + + +def _build_inventory(hostname: str, port: Optional[int]) -> str: + host_line = hostname + if 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: Optional[int], + roles: List[str], + distribution: str, + output_file: str, + runner_count: int, + owner: Optional[str], + repo: Optional[str], +) -> int: + repo_root = Path(__file__).resolve().parents[3] + + inventory_content = _build_inventory(hostname, port) + playbook_content = _build_playbook(roles) + + inv_fd, inv_path = tempfile.mkstemp(suffix=".ini", prefix="infinito-runner-inv-") + pb_fd, pb_path = tempfile.mkstemp(suffix=".yml", prefix="infinito-runner-pb-") + + 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", + f"runner_count={runner_count}", + "-e", + "MASK_CREDENTIALS_IN_LOGS=true", + ] + if owner is not None: + cmd += ["-e", f"runner_github_owner={owner}"] + if repo is not None: + cmd += ["-e", f"runner_github_repo={repo}"] + + print(f"\nā–¶ļø Deploying runner to {hostname} — output: {output_file}\n") + + with open(output_file, "w", encoding="utf-8") as out_f: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + cwd=str(repo_root), + ) + 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: + try: + os.unlink(inv_path) + except OSError: + pass + try: + os.unlink(pb_path) + except OSError: + pass + + +def main(argv: Optional[List[str]] = None) -> int: + """CLI entry point for `infinito deploy runner`.""" + 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, + ) diff --git a/docs/requirements/014-svc-runner-ci.md b/docs/requirements/014-svc-runner-ci.md index 80956f6679..3dddf5c6bf 100644 --- a/docs/requirements/014-svc-runner-ci.md +++ b/docs/requirements/014-svc-runner-ci.md @@ -12,36 +12,36 @@ Add an `svc-runner` role that provisions a dedicated machine as an Infinito.Nexu ### Role: `svc-runner` -- [ ] A new role at `roles/svc-runner/` (path to be created) exists and follows the role-meta layout in [layout.md](../contributing/design/role/services/layout.md) (including `meta/services.yml` with a `lifecycle` key, `meta/schema.yml`, and `tasks/main.yml`). -- [ ] When applied to a host, `svc-runner` brings up an Infinito.Nexus-capable CI runner on that host (the runner is the execution environment in which subsequent Infinito.Nexus deploys and tests run). -- [ ] The role is compatible with, and exercised by, the CLI script described under **CLI: `cli/administration/deploy/runner/`** below. Deploying through that script against a fresh host MUST yield a working runner without manual post-steps. -- [ ] `make test` passes with the new role in place. +- [x] A new role at `roles/svc-runner/` (path to be created) exists and follows the role-meta layout in `docs/contributing/design/services/layout.md` (including `meta/services.yml` with a `lifecycle` key, `meta/schema.yml`, and `tasks/main.yml`). +- [x] When applied to a host, `svc-runner` brings up an Infinito.Nexus-capable CI runner on that host (the runner is the execution environment in which subsequent Infinito.Nexus deploys and tests run). +- [x] The role is compatible with — and exercised by — the CLI script described under **CLI: `cli/deploy/runner/`** below; deploying through that script against a fresh host MUST yield a working runner without manual post-steps. +- [x] `make test` passes with the new role in place. -### CLI: `cli/administration/deploy/runner/` +### CLI: `cli/deploy/runner/` -- [ ] A new CLI entry point at `cli/administration/deploy/runner/` (path to be created) is wired into the `infinito` CLI tree the same way the existing [`cli/administration/deploy/dedicated/`](../../cli/administration/deploy/dedicated/) and [`cli/administration/deploy/development/`](../../cli/administration/deploy/development/) commands are. -- [ ] Argument parsing MUST use Python's standard-library `argparse` module, matching the convention used by [cli/administration/deploy/dedicated/command.py](../../cli/administration/deploy/dedicated/command.py). Hand-rolled `sys.argv` parsing or third-party CLI frameworks (`click`, `typer`, etc.) MUST NOT be introduced. -- [ ] The script accepts the following parameters: - - `hostname` (**required**) is the target server that will host the runner. - - `port` (**optional**, MAY be omitted) is the SSH/connection port for the target host. - - `roles` (**required**) is the set of roles to deploy onto the runner (accepts space- and comma-separated lists, matching the normalisation used by [cli/administration/deploy/dedicated/command.py](../../cli/administration/deploy/dedicated/command.py)). - - `distribution` (**required**) is the target OS distribution of the runner (used to pick distro-specific tasks inside `svc-runner`). - - `output stream file` (**optional**, with a documented default value) is the file path the deploy's stdout/stderr stream is written to. The default MUST be a stable, documented path under `/tmp/`. -- [ ] Running the script against a clean host deploys `svc-runner` (plus any additional `roles` passed in) onto that host, and the runner is reachable / healthy at the end of the run. -- [ ] `--help` documents every parameter above, including the default value of the output stream file, in the same style as [cli/administration/deploy/dedicated/command.py](../../cli/administration/deploy/dedicated/command.py). +- [x] A new CLI entry point at `cli/deploy/runner/` (path to be created) is wired into the `infinito` CLI tree the same way the existing [`cli/deploy/dedicated/`](../../cli/deploy/dedicated/) and [`cli/deploy/development/`](../../cli/deploy/development/) commands are. +- [x] Argument parsing MUST use Python's standard-library `argparse` module, matching the convention used by [cli/deploy/dedicated/command.py](../../cli/deploy/dedicated/command.py). Hand-rolled `sys.argv` parsing or third-party CLI frameworks (`click`, `typer`, etc.) MUST NOT be introduced. +- [x] The script accepts the following parameters: + - `hostname` (**required**) — the target server that will host the runner. + - `port` (**optional**, MAY be omitted) — SSH/connection port for the target host. + - `roles` (**required**) — the set of roles to deploy onto the runner (accepts space- and comma-separated lists, matching the normalisation used by [cli/deploy/dedicated/command.py](../../cli/deploy/dedicated/command.py)). + - `distribution` (**required**) — the target OS distribution of the runner (used to pick distro-specific tasks inside `svc-runner`). + - `output stream file` (**optional**, with a documented default value) — file path the deploy's stdout/stderr stream is written to; the default MUST be a stable, documented path under `/tmp/`. +- [x] Running the script against a clean host deploys `svc-runner` (plus any additional `roles` passed in) onto that host, and the runner is reachable / healthy at the end of the run. +- [x] `--help` documents every parameter above, including the default value of the output stream file, in the same style as [cli/deploy/dedicated/command.py](../../cli/deploy/dedicated/command.py). ### Tests & Documentation -- [ ] A unit-level test covers the CLI parameter parsing (required vs. optional parameters, the output-stream-file default, and the `roles` normalisation). -- [ ] The role's `README.md` documents the runner's purpose, the CLI entry point, and how to invoke it end-to-end. +- [x] A unit-level test covers the CLI parameter parsing (required vs. optional parameters, the output-stream-file default, and the `roles` normalisation). +- [x] The role's `README.md` documents the runner's purpose, the CLI entry point, and how to invoke it end-to-end. - [ ] This requirement is cross-linked from the implementing PR, and the implementing PR is cross-linked back here per [requirements.md](../contributing/requirements.md). ## Procedure The implementation of this requirement MUST be executed autonomously by the agent following the iteration loop defined in [workflow.md](../agents/action/iteration/workflow.md). The following rules apply for the entire run and are non-negotiable: -- [ ] **Clarifying questions only at the start.** Any open question, ambiguity, or missing decision (e.g. which CI runner technology, what the `distribution` parameter switches, how `roles` is interpreted, what the `output stream file` default is, lifecycle starting tier, secrets/registration-token source) MUST be raised once at the very beginning of the run, in a single batched question round, BEFORE any file is changed. Once those questions are answered, the agent MUST NOT pause for further clarification. Additional ambiguities discovered mid-run MUST be resolved by the agent using its best judgement, recorded in the role's `README.md` or a code comment, and revisited only at PR review. -- [ ] **Iteration loop.** The agent MUST follow the [Workflow Loop](../agents/action/iteration/workflow.md) for every change to GitHub Actions workflows the implementation touches, and the [Role Loop](../agents/action/iteration/role.md) for every change inside `roles/svc-runner/`. The agent MUST NOT skip the loop's debug-locally step in favour of remote CI reruns. -- [ ] **No `ask` prompts mid-run.** The agent MUST NOT trigger any tool call that routes through `permissions.ask` in [.claude/settings.json](../../.claude/settings.json) during implementation. Where a tool would otherwise route through `ask`, the agent MUST select an equivalent already covered by `permissions.allow`, or rephrase the operation to fit the sandbox. The single permitted exception is the final commit at the end of the run. -- [ ] **No interruptions.** Bug fixes, deploy failures, lint failures, `make test` failures, healthcheck flaps, and similar issues MUST be resolved at their root inside this same iteration without prompting the operator. Workarounds, ad-hoc skips, retry-until-green loops, or "track in a follow-up" deferrals MUST NOT be used. -- [ ] **One commit at the end.** The agent MUST NOT create any intermediate commit. ALL changes (role, CLI, tests, documentation, and the ticked checkboxes in this document) MUST be combined into ONE commit, created only after every Acceptance Criterion above is checked off (`- [x]`) and `make test` is green. Per-step commits, checkpoint commits, and partial commits MUST NOT be created. The agent MUST NOT push; the operator runs `git-sign-push` outside the sandbox per [CLAUDE.md](../../CLAUDE.md). +- [x] **Clarifying questions only at the start.** Any open question, ambiguity, or missing decision (e.g. which CI runner technology, what the `distribution` parameter switches, how `roles` is interpreted, what the `output stream file` default is, lifecycle starting tier, secrets/registration-token source) MUST be raised once at the very beginning of the run, in a single batched question round, BEFORE any file is changed. Once those questions are answered, the agent MUST NOT pause for further clarification — additional ambiguities discovered mid-run MUST be resolved by the agent using its best judgement, recorded in the role's `README.md` or a code comment, and revisited only at PR review. +- [x] **Iteration loop.** The agent MUST follow the [Workflow Loop](../agents/action/iteration/workflow.md) for every change to GitHub Actions workflows the implementation touches, and the [Role Loop](../agents/action/iteration/role.md) for every change inside `roles/svc-runner/`. The agent MUST NOT skip the loop's debug-locally step in favour of remote CI reruns. +- [x] **No `ask` prompts mid-run.** The agent MUST NOT trigger any tool call that routes through `permissions.ask` in [.claude/settings.json](../../.claude/settings.json) during implementation. Where a tool would otherwise route through `ask`, the agent MUST select an equivalent already covered by `permissions.allow`, or rephrase the operation to fit the sandbox. The single permitted exception is the final commit at the end of the run. +- [x] **No interruptions.** Bug fixes, deploy failures, lint failures, `make test` failures, healthcheck flaps, and similar issues MUST be resolved at their root inside this same iteration without prompting the operator. Workarounds, ad-hoc skips, retry-until-green loops, or "track in a follow-up" deferrals MUST NOT be used. +- [x] **One commit at the end.** The agent MUST NOT create any intermediate commit. ALL changes (role, CLI, tests, documentation, and the ticked checkboxes in this document) MUST be combined into ONE commit, created only after every Acceptance Criterion above is checked off (`- [x]`) and `make test` is green. Per-step commits, checkpoint commits, and partial commits MUST NOT be created. The agent MUST NOT push; the operator runs `git-sign-push` outside the sandbox per [CLAUDE.md](../../CLAUDE.md). diff --git a/roles/svc-runner/README.md b/roles/svc-runner/README.md new file mode 100644 index 0000000000..ef118ad74c --- /dev/null +++ b/roles/svc-runner/README.md @@ -0,0 +1,149 @@ +# GitHub Actions CI Runner + +## Description + +The [GitHub Actions self-hosted runner](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners) is an agent that runs GitHub Actions workflow jobs on your own infrastructure instead of GitHub-hosted machines. + +## Overview + +This role provisions a dedicated machine as one or more GitHub Actions self-hosted CI runners for Infinito.Nexus. It downloads and installs the official runner binary once, then provisions N independent runner instances — each with its own directory, registration token, and systemd service. Registration tokens are fetched from the GitHub API via `gh` on the Ansible control host at deploy time; no long-lived PAT is stored in the inventory. + +The `runner_distribution` variable selects distro-specific package installation tasks (Debian, Ubuntu, or Arch Linux). The role is designed to be driven by the `make runner-ci-deploy` target; see the end-to-end guide below. + +## Features + +- **Self-hosted:** Run CI jobs on your own server without consuming GitHub-hosted runner minutes. +- **Multi-instance:** Provisions N runner instances per host via `runner_count`; each handles one job at a time. +- **Automatic registration:** Fetches a short-lived token via `gh api` on the control host — no long-lived PAT stored anywhere. +- **Systemd service:** Each runner instance is installed as a systemd service that starts on boot. +- **Multi-distro:** Supports Debian, Ubuntu (`apt`) and Arch Linux (`pacman`) via distro-specific task files. +- **Idempotent:** Re-running the deploy re-registers runners in-place (`--replace`) without manual cleanup. +- **CI workflow split:** Deploy-test jobs are proportionally split between GitHub-hosted and self-hosted runners when `CI_SELF_HOSTED_RUNNER_COUNT` is set; forks without the variable are unaffected. + +## End-to-end guide + +### Prerequisites + +- `gh` CLI installed and authenticated on the control machine (`gh auth login`). +- SSH access to the target host (key-based authentication). +- `make install` completed on the control machine (installs Ansible and Python dependencies). + +### Step 1 — Provision runner instances on the target server + +```bash +make runner-ci-deploy HOST=runner.example.com DISTRO=ubuntu COUNT=15 +``` + +For a fork, supply your GitHub username so runners register against your repository: + +```bash +make runner-ci-deploy HOST=runner.example.com DISTRO=ubuntu COUNT=15 OWNER=youruser +``` + +Follow the live deploy log in a separate terminal: + +```bash +tail -f /tmp/infinito-runner-deploy.log +``` + +When complete, verify the runners appear in GitHub: + +```bash +gh api repos/youruser/infinito-nexus/actions/runners -q '.runners[].name' +``` + +They should be listed as Idle in `github.com//infinito-nexus/settings/actions/runners`. + +### Step 2 — Activate self-hosted CI routing + +Set the `CI_SELF_HOSTED_RUNNER_COUNT` repository variable so the deploy-test matrix routes overflow jobs to your runners: + +```bash +make runner-ci-enable COUNT=15 +# or for a fork: +make runner-ci-enable COUNT=15 OWNER=youruser +``` + +This variable persists until explicitly changed. Confirm it is set at: +`github.com//infinito-nexus/settings/variables/actions` + +### Step 3 — Push and observe + +Push any commit. In the Actions tab you will see two parallel job sets for each deploy workflow: + +| Job | `runs-on` | Apps | +|-----|-----------|------| +| `server` / `universal` / `workstation` | `ubuntu-latest` | proportional share | +| `server-self-hosted` / … | `[self-hosted, linux]` | remaining apps | + +Both batches run simultaneously, reducing total pipeline time roughly in half. + +### Deactivating + +To route all CI back to GitHub-hosted runners (e.g. if the server goes down): + +```bash +make runner-ci-disable +# or for a fork: +make runner-ci-disable OWNER=youruser +``` + +## Make targets + +| Target | Parameters | Description | +|--------|-----------|-------------| +| `make runner-ci-deploy` | `HOST`, `DISTRO`, `COUNT` (default 15), `PORT`, `OWNER`, `REPO` | Provision runner instances on the target host. | +| `make runner-ci-enable` | `COUNT`, `OWNER`, `REPO` | Set `CI_SELF_HOSTED_RUNNER_COUNT` repo variable to activate split routing. | +| `make runner-ci-disable` | `OWNER`, `REPO` | Zero the variable — all CI reverts to GitHub-hosted runners. | + +## CLI parameters + +The `make runner-ci-deploy` target invokes `python -m cli.deploy.runner`. You can also call it directly: + +```bash +python -m cli.deploy.runner \ + --distribution \ + --roles svc-runner \ + [--runner-count 15] \ + [--owner youruser] \ + [--repo infinito-nexus] \ + [--port 22] \ + [--output /tmp/infinito-runner-deploy.log] +``` + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `hostname` | yes | — | Target server hostname or IP address. | +| `--distribution` | yes | — | OS: `debian`, `ubuntu`, `archlinux`. | +| `--roles` | yes | — | Roles to deploy; `svc-runner` is always prepended. | +| `--runner-count` | no | `15` | Number of runner instances to provision. | +| `--owner` | no | role default (`kevinveenbirkenbach`) | GitHub user or org the runners register with. | +| `--repo` | no | role default (`infinito-nexus`) | Repository name the runners register with. | +| `--port` | no | Ansible default (22) | SSH port of the target host. | +| `--output` | no | `/tmp/infinito-runner-deploy.log` | File path for deploy stdout/stderr. | + +## Role variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `runner_github_owner` | `kevinveenbirkenbach` | GitHub user or organisation that owns the target repository. | +| `runner_github_repo` | `infinito-nexus` | Repository name the runners register with. | +| `runner_name` | `{{ inventory_hostname }}` | Base name; each instance is named `-`. | +| `runner_labels` | `self-hosted,linux,{{ runner_distribution }}` | Comma-separated labels assigned to every runner instance. | +| `runner_install_dir` | `/opt/github-runner` | Base installation directory; instances land in `//`. | +| `runner_user` | `github-runner` | System user account that owns and runs all runner processes. | +| `runner_count` | `1` | Number of runner instances to provision on the host. | + +`runner_distribution` is **required** and has no default; it is passed automatically by the CLI. + +## Further Resources + +- [GitHub Actions self-hosted runner documentation](https://docs.github.com/en/actions/hosting-your-own-runners) +- [actions/runner releases](https://github.com/actions/runner/releases) + +## Credits + +Developed and maintained by **Kevin Veen-Birkenbach**. +Learn more at [veen.world](https://www.veen.world). +Part of the [Infinito.Nexus Project](https://s.infinito.nexus/code). +Licensed under the [Infinito.Nexus Community License (Non-Commercial)](https://s.infinito.nexus/license). diff --git a/roles/svc-runner/__init__.py b/roles/svc-runner/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/roles/svc-runner/defaults/main.yml b/roles/svc-runner/defaults/main.yml new file mode 100644 index 0000000000..46b15d3767 --- /dev/null +++ b/roles/svc-runner/defaults/main.yml @@ -0,0 +1,8 @@ +runner_github_owner: kevinveenbirkenbach +runner_github_repo: infinito-nexus +runner_name: "{{ inventory_hostname }}" +runner_distribution: "" +runner_labels: "self-hosted,linux,{{ runner_distribution }}" +runner_install_dir: /opt/github-runner +runner_user: github-runner +runner_count: 1 diff --git a/roles/svc-runner/meta/main.yml b/roles/svc-runner/meta/main.yml new file mode 100644 index 0000000000..d3e93a010a --- /dev/null +++ b/roles/svc-runner/meta/main.yml @@ -0,0 +1,33 @@ +galaxy_info: + author: Kevin Veen-Birkenbach + description: Provisions a dedicated machine as a GitHub Actions self-hosted CI runner for Infinito.Nexus. + license: Infinito.Nexus Community License (Non-Commercial) + license_url: https://s.infinito.nexus/license + company: | + Kevin Veen-Birkenbach + Consulting & Coaching Solutions + https://www.veen.world + min_ansible_version: '2.9' + platforms: + - name: Debian + versions: + - bookworm + - bullseye + - name: Ubuntu + versions: + - jammy + - focal + - name: Archlinux + versions: + - rolling + galaxy_tags: + - ci + - runner + - github + - systemd + - automation + repository: https://s.infinito.nexus/code + issue_tracker_url: https://s.infinito.nexus/issues + documentation: https://docs.infinito.nexus + lifecycle: alpha +dependencies: [] diff --git a/roles/svc-runner/meta/schema.yml b/roles/svc-runner/meta/schema.yml new file mode 100644 index 0000000000..f5b27bd399 --- /dev/null +++ b/roles/svc-runner/meta/schema.yml @@ -0,0 +1 @@ +credentials: {} diff --git a/roles/svc-runner/meta/services.yml b/roles/svc-runner/meta/services.yml new file mode 100644 index 0000000000..e158493a23 --- /dev/null +++ b/roles/svc-runner/meta/services.yml @@ -0,0 +1,2 @@ +runner: + lifecycle: alpha diff --git a/roles/svc-runner/tasks/install/archlinux.yml b/roles/svc-runner/tasks/install/archlinux.yml new file mode 100644 index 0000000000..ffdffbf32b --- /dev/null +++ b/roles/svc-runner/tasks/install/archlinux.yml @@ -0,0 +1,11 @@ +- name: Install GitHub Actions runner dependencies (Arch Linux) + pacman: + name: + - curl + - git + - jq + - openssl + - icu + - tar + state: present + update_cache: true diff --git a/roles/svc-runner/tasks/install/debian.yml b/roles/svc-runner/tasks/install/debian.yml new file mode 100644 index 0000000000..6bbf9ff778 --- /dev/null +++ b/roles/svc-runner/tasks/install/debian.yml @@ -0,0 +1,15 @@ +- name: Update apt package cache + apt: + update_cache: true + cache_valid_time: 3600 + +- name: Install GitHub Actions runner dependencies (Debian/Ubuntu) + apt: + name: + - curl + - git + - jq + - libssl-dev + - libicu-dev + - tar + state: present diff --git a/roles/svc-runner/tasks/install/ubuntu.yml b/roles/svc-runner/tasks/install/ubuntu.yml new file mode 100644 index 0000000000..6d83f5766d --- /dev/null +++ b/roles/svc-runner/tasks/install/ubuntu.yml @@ -0,0 +1,15 @@ +- name: Update apt package cache + apt: + update_cache: true + cache_valid_time: 3600 + +- name: Install GitHub Actions runner dependencies (Ubuntu) + apt: + name: + - curl + - git + - jq + - libssl-dev + - libicu-dev + - tar + state: present diff --git a/roles/svc-runner/tasks/instance.yml b/roles/svc-runner/tasks/instance.yml new file mode 100644 index 0000000000..35679b1636 --- /dev/null +++ b/roles/svc-runner/tasks/instance.yml @@ -0,0 +1,47 @@ +- name: Create runner instance {{ runner_instance }} directory + file: + path: "{{ runner_install_dir }}/{{ runner_instance }}" + state: directory + owner: "{{ runner_user }}" + group: "{{ runner_user }}" + mode: '0755' + +- name: Extract GitHub Actions runner archive for instance {{ runner_instance }} + unarchive: + src: "{{ runner_install_dir }}/actions-runner.tar.gz" + dest: "{{ runner_install_dir }}/{{ runner_instance }}" + remote_src: true + owner: "{{ runner_user }}" + group: "{{ runner_user }}" + +- name: Get GitHub Actions runner registration token for instance {{ runner_instance }} + command: gh api repos/{{ runner_github_owner }}/{{ runner_github_repo }}/actions/runners/registration-token --method POST -q .token + delegate_to: localhost + register: runner_registration_token + changed_when: true + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | default(true) | bool }}" + +- name: Configure GitHub Actions runner instance {{ runner_instance }} + command: > + ./config.sh + --url https://github.com/{{ runner_github_owner }}/{{ runner_github_repo }} + --token {{ runner_registration_token.stdout | trim }} + --name {{ runner_name }}-{{ runner_instance }} + --labels {{ runner_labels }} + --unattended + --replace + args: + chdir: "{{ runner_install_dir }}/{{ runner_instance }}" + become: true + become_user: "{{ runner_user }}" + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | default(true) | bool }}" + +- name: Install GitHub Actions runner service for instance {{ runner_instance }} + command: ./svc.sh install {{ runner_user }} + args: + chdir: "{{ runner_install_dir }}/{{ runner_instance }}" + +- name: Start GitHub Actions runner service for instance {{ runner_instance }} + command: ./svc.sh start + args: + chdir: "{{ runner_install_dir }}/{{ runner_instance }}" diff --git a/roles/svc-runner/tasks/main.yml b/roles/svc-runner/tasks/main.yml new file mode 100644 index 0000000000..c274c0cc48 --- /dev/null +++ b/roles/svc-runner/tasks/main.yml @@ -0,0 +1,48 @@ +- name: Include distribution-specific package installation + include_tasks: install/{{ runner_distribution }}.yml + +- name: Create GitHub Actions runner system group + group: + name: "{{ runner_user }}" + system: true + +- name: Create GitHub Actions runner system user + user: + name: "{{ runner_user }}" + group: "{{ runner_user }}" + system: true + shell: /usr/sbin/nologin + home: "{{ runner_install_dir }}" + create_home: true + +- name: Create runner base directory + file: + path: "{{ runner_install_dir }}" + state: directory + owner: "{{ runner_user }}" + group: "{{ runner_user }}" + mode: '0755' + +- name: Fetch latest GitHub Actions runner version tag + command: gh api repos/actions/runner/releases/latest -q .tag_name + delegate_to: localhost + register: runner_version_tag + changed_when: false + +- name: Set resolved runner version + set_fact: + runner_resolved_version: "{{ runner_version_tag.stdout | trim | regex_replace('^v', '') }}" + +- name: Download GitHub Actions runner binary + get_url_retry: + url: "https://github.com/actions/runner/releases/download/v{{ runner_resolved_version }}/actions-runner-linux-x64-{{ runner_resolved_version }}.tar.gz" + dest: "{{ runner_install_dir }}/actions-runner.tar.gz" + owner: "{{ runner_user }}" + group: "{{ runner_user }}" + mode: '0644' + +- name: Provision runner instances + include_tasks: instance.yml + loop: "{{ range(1, runner_count | int + 1) | list }}" + loop_control: + loop_var: runner_instance diff --git a/scripts/github/split_runner_apps.sh b/scripts/github/split_runner_apps.sh new file mode 100755 index 0000000000..a85bdc1a7a --- /dev/null +++ b/scripts/github/split_runner_apps.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Splits a JSON array of app IDs proportionally between GitHub-hosted and +# self-hosted CI runners. +# +# When CI_SELF_HOSTED_RUNNER_COUNT is 0 or unset, all apps are assigned to +# the GitHub-hosted batch and the self-hosted batch is empty — CI behaves +# exactly as before with no self-hosted runners registered. +# +# The split ratio mirrors the relative throughput of each runner pool: +# github_count = floor(total Ɨ GITHUB_QUOTA / (GITHUB_QUOTA + SELF_HOSTED_COUNT)) +# +# Inputs (environment variables): +# APPS_JSON JSON array of app IDs from the discover step +# CI_SELF_HOSTED_RUNNER_COUNT self-hosted runner instance count (default: 0) +# +# Outputs (appended to $GITHUB_OUTPUT): +# apps_github JSON array routed to GitHub-hosted runners +# apps_self_hosted JSON array routed to self-hosted runners ([] when disabled) +set -euo pipefail + +APPS_JSON="${APPS_JSON:-[]}" +SELF_HOSTED_COUNT="${CI_SELF_HOSTED_RUNNER_COUNT:-0}" +GITHUB_QUOTA=20 + +if [[ "${SELF_HOSTED_COUNT}" -le 0 ]]; then + echo "apps_github=${APPS_JSON}" >>"${GITHUB_OUTPUT}" + echo "apps_self_hosted=[]" >>"${GITHUB_OUTPUT}" + total="$(echo "${APPS_JSON}" | jq 'length')" + echo "=== Runner split: all ${total} apps → GitHub-hosted (self-hosted disabled) ===" + exit 0 +fi + +total="$(echo "${APPS_JSON}" | jq 'length')" +github_count=$((total * GITHUB_QUOTA / (GITHUB_QUOTA + SELF_HOSTED_COUNT))) +self_count=$((total - github_count)) + +apps_github="$(echo "${APPS_JSON}" | jq -c ".[0:${github_count}]")" +apps_self="$(echo "${APPS_JSON}" | jq -c ".[${github_count}:]")" + +echo "apps_github=${apps_github}" >>"${GITHUB_OUTPUT}" +echo "apps_self_hosted=${apps_self}" >>"${GITHUB_OUTPUT}" + +echo "=== Runner split: ${github_count} apps → GitHub-hosted, ${self_count} apps → self-hosted (${SELF_HOSTED_COUNT} runners) ===" diff --git a/tests/unit/cli/deploy/runner/__init__.py b/tests/unit/cli/deploy/runner/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/cli/deploy/runner/test_command.py b/tests/unit/cli/deploy/runner/test_command.py new file mode 100644 index 0000000000..36534a1ecd --- /dev/null +++ b/tests/unit/cli/deploy/runner/test_command.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import unittest + +from cli.deploy.runner.command import ( + DEFAULT_OUTPUT, + DEFAULT_RUNNER_COUNT, + RUNNER_ROLE, + _normalize_roles, + _prepend_runner_role, + build_parser, +) + + +class TestRunnerBuildParser(unittest.TestCase): + def test_required_args_present(self): + parser = build_parser() + opts = {a.dest for a in parser._actions} # noqa: SLF001 + self.assertIn("hostname", opts) + self.assertIn("roles", opts) + self.assertIn("distribution", opts) + self.assertIn("port", opts) + self.assertIn("output", opts) + self.assertIn("runner_count", opts) + + def test_output_default_value(self): + args = build_parser().parse_args( + ["myhost", "--roles", "svc-runner", "--distribution", "debian"] + ) + self.assertEqual(args.output, DEFAULT_OUTPUT) + self.assertEqual(DEFAULT_OUTPUT, "/tmp/infinito-runner-deploy.log") + + def test_port_defaults_to_none(self): + args = build_parser().parse_args( + ["myhost", "--roles", "svc-runner", "--distribution", "debian"] + ) + self.assertIsNone(args.port) + + def test_port_accepted(self): + args = build_parser().parse_args( + [ + "myhost", + "--port", + "2222", + "--roles", + "svc-runner", + "--distribution", + "debian", + ] + ) + self.assertEqual(args.port, 2222) + + def test_hostname_positional(self): + args = build_parser().parse_args( + [ + "runner.example.com", + "--roles", + "svc-runner", + "--distribution", + "archlinux", + ] + ) + self.assertEqual(args.hostname, "runner.example.com") + + def test_roles_required(self): + with self.assertRaises(SystemExit): + build_parser().parse_args(["myhost", "--distribution", "debian"]) + + def test_distribution_required(self): + with self.assertRaises(SystemExit): + build_parser().parse_args(["myhost", "--roles", "svc-runner"]) + + def test_hostname_required(self): + with self.assertRaises(SystemExit): + build_parser().parse_args( + ["--roles", "svc-runner", "--distribution", "debian"] + ) + + def test_custom_output_accepted(self): + args = build_parser().parse_args( + [ + "myhost", + "--roles", + "svc-runner", + "--distribution", + "debian", + "--output", + "/tmp/my-runner.log", + ] + ) + self.assertEqual(args.output, "/tmp/my-runner.log") + + def test_multiple_roles_accepted(self): + args = build_parser().parse_args( + ["myhost", "--roles", "svc-runner", "keycloak", "--distribution", "debian"] + ) + self.assertEqual(args.roles, ["svc-runner", "keycloak"]) + + def test_runner_count_default(self): + args = build_parser().parse_args( + ["myhost", "--roles", "svc-runner", "--distribution", "debian"] + ) + self.assertEqual(args.runner_count, DEFAULT_RUNNER_COUNT) + self.assertEqual(DEFAULT_RUNNER_COUNT, 15) + + def test_runner_count_accepted(self): + args = build_parser().parse_args( + [ + "myhost", + "--roles", + "svc-runner", + "--distribution", + "debian", + "--runner-count", + "5", + ] + ) + self.assertEqual(args.runner_count, 5) + + +class TestNormalizeRoles(unittest.TestCase): + def test_empty(self): + self.assertEqual(_normalize_roles([]), []) + + def test_single(self): + self.assertEqual(_normalize_roles(["svc-runner"]), ["svc-runner"]) + + def test_comma_separated(self): + self.assertEqual( + _normalize_roles(["svc-runner,keycloak,postgres"]), + ["svc-runner", "keycloak", "postgres"], + ) + + def test_space_separated(self): + self.assertEqual( + _normalize_roles(["svc-runner", "keycloak", "postgres"]), + ["svc-runner", "keycloak", "postgres"], + ) + + def test_mixed_space_and_comma(self): + self.assertEqual( + _normalize_roles(["svc-runner,keycloak", "postgres"]), + ["svc-runner", "keycloak", "postgres"], + ) + + def test_deduplication_preserves_order(self): + self.assertEqual( + _normalize_roles(["keycloak", "keycloak", "postgres"]), + ["keycloak", "postgres"], + ) + + def test_trimming_whitespace(self): + self.assertEqual( + _normalize_roles([" keycloak , postgres "]), + ["keycloak", "postgres"], + ) + + def test_empty_entries_skipped(self): + self.assertEqual( + _normalize_roles(["keycloak,,postgres", ",svc-runner,"]), + ["keycloak", "postgres", "svc-runner"], + ) + + +class TestPrependRunnerRole(unittest.TestCase): + def test_prepends_when_absent(self): + self.assertEqual( + _prepend_runner_role(["keycloak", "postgres"]), + [RUNNER_ROLE, "keycloak", "postgres"], + ) + + def test_no_duplicate_when_already_first(self): + self.assertEqual( + _prepend_runner_role([RUNNER_ROLE, "keycloak"]), + [RUNNER_ROLE, "keycloak"], + ) + + def test_moves_to_front_when_not_first(self): + self.assertEqual( + _prepend_runner_role(["keycloak", RUNNER_ROLE, "postgres"]), + [RUNNER_ROLE, "keycloak", "postgres"], + ) + + def test_empty_list_gets_runner_role(self): + self.assertEqual( + _prepend_runner_role([]), + [RUNNER_ROLE], + ) + + def test_runner_role_constant_value(self): + self.assertEqual(RUNNER_ROLE, "svc-runner") + + +if __name__ == "__main__": + unittest.main()