From 94211772c91a1821baf1febf5f872e4484628c9a Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 25 Apr 2026 11:37:38 -0500 Subject: [PATCH 1/4] Fix drift counts from runner group inventory --- config/pools.yaml | 5 +++ src/lib/drift.ts | 24 ++++++++----- src/lib/github.ts | 71 +++++++++++++++++++++++++++++++++++++++ test/drift-detect.test.ts | 12 ++----- 4 files changed, 94 insertions(+), 18 deletions(-) diff --git a/config/pools.yaml b/config/pools.yaml index f16b747..10369c4 100644 --- a/config/pools.yaml +++ b/config/pools.yaml @@ -28,8 +28,13 @@ pools: runnerGroup: synology-public repositoryAccess: selected allowedRepositories: + - omt-global/apw-cli - omt-global/axiom + - omt-global/bootstrap + - omt-global/gh-attest - omt-global/github-runner-fleet + - omt-global/home-tv-channel-list + - omt-global/Screensaver labels: - synology - shell-only diff --git a/src/lib/drift.ts b/src/lib/drift.ts index d1a9bf8..0e3ccdc 100644 --- a/src/lib/drift.ts +++ b/src/lib/drift.ts @@ -1,5 +1,6 @@ import type { PoolConfig } from "./config.js"; import { + fetchOrganizationRunnerGroupRunners, fetchOrganizationRunnerGroups, fetchOrganizationRunners, type FetchLike @@ -240,10 +241,12 @@ export async function collectGitHubActualPoolState( const poolsByOrganization = groupByOrganization(desiredPools); for (const [organization, pools] of poolsByOrganization.entries()) { - const [groups, runners] = await Promise.all([ - fetchOrganizationRunnerGroups(apiUrl, organization, token, fetchImpl), - fetchOrganizationRunners(apiUrl, organization, token, fetchImpl) - ]); + const groups = await fetchOrganizationRunnerGroups( + apiUrl, + organization, + token, + fetchImpl + ); for (const pool of pools) { const group = groups.find((entry) => entry.name === pool.runnerGroup); @@ -254,12 +257,17 @@ export async function collectGitHubActualPoolState( ); } + const runners = await fetchOrganizationRunnerGroupRunners( + apiUrl, + organization, + group.id, + token, + fetchImpl + ); + actualPools.push({ name: pool.name, - actual: runners.filter( - (runner) => - runner.runnerGroupId === group.id && runner.status === "online" - ).length + actual: runners.filter((runner) => runner.status === "online").length }); } } diff --git a/src/lib/github.ts b/src/lib/github.ts index e912d1c..31a1b4c 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -332,6 +332,77 @@ export async function fetchOrganizationRunners( } } +export async function fetchOrganizationRunnerGroupRunners( + apiUrl: string, + organization: string, + runnerGroupId: number, + token: string, + fetchImpl: FetchLike = fetch as FetchLike +): Promise { + const runners: GitHubRunner[] = []; + + for (let page = 1; ; page += 1) { + const response = await fetchImpl( + `${trimApiUrl(apiUrl)}/orgs/${organization}/actions/runner-groups/${runnerGroupId}/runners?per_page=100&page=${page}`, + { + method: "GET", + headers: buildGitHubApiHeaders(token) + } + ); + + const body = await response.text(); + if (!response.ok) { + throw new Error( + `GitHub runner group runner lookup failed for ${organization}/${runnerGroupId} with ${response.status}: ${body}` + ); + } + + const payload = JSON.parse(body) as { + runners?: Array<{ + id?: number; + name?: string; + status?: string; + busy?: boolean; + runner_group_id?: number; + labels?: Array<{ name?: string }>; + }>; + }; + + if (!Array.isArray(payload.runners)) { + throw new Error( + `GitHub runner group runner response for ${organization}/${runnerGroupId} did not include runners` + ); + } + + runners.push( + ...payload.runners.map((runner) => { + if (typeof runner.id !== "number" || !runner.name || !runner.status) { + throw new Error( + `GitHub runner group runner response for ${organization}/${runnerGroupId} included an invalid runner entry` + ); + } + + return { + id: runner.id, + name: runner.name, + status: runner.status, + busy: runner.busy, + runnerGroupId: runner.runner_group_id ?? runnerGroupId, + labels: Array.isArray(runner.labels) + ? runner.labels + .map((label) => label.name) + .filter((name): name is string => typeof name === "string") + : [] + }; + }) + ); + + if (payload.runners.length < 100) { + return runners; + } + } +} + export async function deleteOrganizationRunner( apiUrl: string, organization: string, diff --git a/test/drift-detect.test.ts b/test/drift-detect.test.ts index e78734a..f9c6214 100644 --- a/test/drift-detect.test.ts +++ b/test/drift-detect.test.ts @@ -150,20 +150,12 @@ describe("drift detection", () => { { id: 1, name: "runner-1", - status: "online", - runner_group_id: 10 + status: "online" }, { id: 2, name: "runner-2", - status: "offline", - runner_group_id: 10 - }, - { - id: 3, - name: "runner-3", - status: "online", - runner_group_id: 11 + status: "offline" } ] }) From 142c39689067d14c85abe2bbad87fe20c05455d8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Apr 2026 17:26:23 -0500 Subject: [PATCH 2/4] Harden runner surface policy --- .env.example | 3 + .github/workflows/pr-fast-ci.yml | 53 +++- README.md | 7 +- SECURITY.md | 17 ++ config/lume-runners.yaml | 2 +- docs/bootstrap/claude-environment.md | 4 +- docs/bootstrap/codex-cloud-environment.md | 2 +- docs/bootstrap/onboarding.md | 2 +- docs/workflow-cookbook.md | 42 +-- project.bootstrap.yaml | 6 +- scripts/smoke/mock-api.mjs | 5 +- src/cli.ts | 12 +- src/lib/compose.ts | 37 ++- src/lib/doctor.ts | 242 +++++++++++++++++- src/lib/linux-docker-compose.ts | 35 ++- src/lib/linux-docker-config.ts | 73 ++---- src/lib/lume-config.ts | 48 +--- src/lib/runner-plane.ts | 129 ++++++++++ src/lib/windows-compose.ts | 33 ++- src/lib/windows-config.ts | 72 ++---- ...linux-docker-compose.snapshot.test.ts.snap | 4 +- ...linux-docker-install.snapshot.test.ts.snap | 4 +- .../windows-install.snapshot.test.ts.snap | 4 + test/cli.test.ts | 1 + test/compose.test.ts | 7 +- test/doctor.test.ts | 76 ++++++ test/linux-docker-config.test.ts | 45 +++- test/lume-config.test.ts | 27 +- test/smoke-harness.test.ts | 8 +- test/synology-status.test.ts | 20 +- test/windows-config.test.ts | 48 +++- test/workflow-cookbook.test.ts | 8 +- test/workflow.test.ts | 46 ++++ 33 files changed, 863 insertions(+), 259 deletions(-) create mode 100644 src/lib/runner-plane.ts diff --git a/.env.example b/.env.example index 5a19eda..d176de1 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,7 @@ LINUX_DOCKER_PROJECT_ENV_FILE=.env LINUX_DOCKER_INSTALL_PULL_IMAGES=true LINUX_DOCKER_INSTALL_FORCE_RECREATE=true LINUX_DOCKER_INSTALL_REMOVE_ORPHANS=true +LINUX_DOCKER_ALLOW_ALL_REPOSITORIES=false WINDOWS_DOCKER_RUNNER_BASE_DIR=C:\github-runner-fleet\windows-docker WINDOWS_DOCKER_HOST=windows-host.internal WINDOWS_DOCKER_PORT=22 @@ -35,8 +36,10 @@ WINDOWS_DOCKER_PROJECT_ENV_FILE=.env WINDOWS_DOCKER_INSTALL_PULL_IMAGES=true WINDOWS_DOCKER_INSTALL_FORCE_RECREATE=true WINDOWS_DOCKER_INSTALL_REMOVE_ORPHANS=true +WINDOWS_DOCKER_ALLOW_ALL_REPOSITORIES=false LUME_RUNNER_BASE_DIR=~/Library/Application Support/github-runner-fleet/lume LUME_RUNNER_ENV_FILE=~/Library/Application Support/github-runner-fleet/lume/runner.env LUME_RUNNER_IPSW_PATH=~/Library/Application Support/github-runner-fleet/lume/cache/latest.ipsw +LUME_GUEST_PASSWORD=change-me COMPOSE_PROJECT_NAME=github-runner-fleet RUNNER_VERSION=2.333.0 diff --git a/.github/workflows/pr-fast-ci.yml b/.github/workflows/pr-fast-ci.yml index cbf46e9..23d3f8d 100644 --- a/.github/workflows/pr-fast-ci.yml +++ b/.github/workflows/pr-fast-ci.yml @@ -23,7 +23,7 @@ defaults: jobs: changes: name: Detect Relevant Changes - runs-on: ['self-hosted', 'synology', 'shell-only', 'public'] + runs-on: ubuntu-latest outputs: app: ${{ steps.filter.outputs.app }} ci: ${{ steps.filter.outputs.ci }} @@ -62,6 +62,7 @@ jobs: needs: changes if: >- github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository && (needs.changes.outputs.app == 'true' || needs.changes.outputs.ci == 'true') steps: - uses: actions/checkout@v6 @@ -75,7 +76,51 @@ jobs: name: Validate Secrets runs-on: ['self-hosted', 'synology', 'shell-only', 'public'] timeout-minutes: 10 - if: github.event.pull_request.draft == false + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Scan repository for secret patterns + run: bash scripts/check-detect-secrets.sh --all-files + + hosted-fork-fast-checks: + name: Hosted Fork Fast Checks + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: changes + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name != github.repository && + (needs.changes.outputs.app == 'true' || needs.changes.outputs.ci == 'true') + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: pnpm/action-setup@v6 + with: + version: 10.32.1 + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm test + - run: pnpm build + + hosted-fork-validate-secrets: + name: Hosted Fork Validate Secrets + runs-on: ubuntu-latest + timeout-minutes: 10 + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name != github.repository steps: - uses: actions/checkout@v6 with: @@ -91,6 +136,8 @@ jobs: - changes - fast-checks - validate-secrets + - hosted-fork-fast-checks + - hosted-fork-validate-secrets steps: - name: Check required PR jobs env: @@ -98,6 +145,8 @@ jobs: changes=${{ needs.changes.result }} fast-checks=${{ needs.fast-checks.result }} validate-secrets=${{ needs.validate-secrets.result }} + hosted-fork-fast-checks=${{ needs.hosted-fork-fast-checks.result }} + hosted-fork-validate-secrets=${{ needs.hosted-fork-validate-secrets.result }} run: | failed=0 for entry in $RESULTS; do diff --git a/README.md b/README.md index 040f7c9..e11d959 100644 --- a/README.md +++ b/README.md @@ -352,12 +352,17 @@ Keep the Lume runner env file outside git and locked down with `chmod 600`. The - Do not add Compose `init: true` for these services. The image already uses `tini`, and double-init setups on Synology produce noisy subreaper warnings. - For public pools, use DSM firewall rules to reduce unnecessary LAN reachability. - Keep the Docker socket restricted to the dedicated Linux Docker host. Do not mount it into the Synology shell-only plane. +- Keep external fork pull requests on GitHub-hosted runners; self-hosted runner groups are for trusted same-repository or explicitly allowed private workflows. +- Treat Docker-capable runner groups as host-control boundaries because they mount the Docker socket or Windows named pipe. +- Keep `LUME_GUEST_PASSWORD` out of git and rotate the base VM guest credential when rebuilding or resealing Lume images. ## Useful Commands ```bash pnpm doctor -- full --env .env pnpm doctor -- synology --env .env +pnpm doctor -- linux-docker --env .env +pnpm doctor -- windows-docker --env .env pnpm doctor -- lume --env .env pnpm validate-linux-docker-config -- --config config/linux-docker-runners.yaml --env .env pnpm validate-linux-docker-github -- --config config/linux-docker-runners.yaml --env .env @@ -416,7 +421,7 @@ SMOKE_KEEP_ARTIFACTS=1 pnpm smoke-test ## Troubleshooting Starting Points -- `pnpm doctor -- full --env .env` for one preflight/status summary across Synology and Lume checks +- `pnpm doctor -- full --env .env` for one preflight/status summary across Synology, Linux Docker, Windows Docker, and Lume checks - `pnpm validate-linux-docker-config -- --config config/linux-docker-runners.yaml --env .env` for Docker-capable Linux pool schema and label validation - `pnpm validate-linux-docker-github -- --config config/linux-docker-runners.yaml --env .env` for Docker-capable Linux runner-group verification - `pnpm render-linux-docker-project-manifest -- --config config/linux-docker-runners.yaml --env .env` for the remote Linux Docker install plan before you push it diff --git a/SECURITY.md b/SECURITY.md index c8951d4..31ba892 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,3 +20,20 @@ We will acknowledge receipt, validate the report, and coordinate a fix and discl ## Scope Notes This repository publishes software intended to manage self-hosted GitHub Actions runners. Public repositories should not route untrusted workflow code to privileged or host-sensitive runner environments. + +External fork pull requests must stay on GitHub-hosted runners. Self-hosted +runner groups are for trusted same-repository or explicitly allowed private +repository workflows only. + +Runner registration currently uses `GITHUB_PAT` to mint short-lived runner +registration and removal tokens. Treat that PAT as fleet-wide infrastructure +auth: keep it out of images and base VMs, stage it only through generated +environment files or GitHub environments, rotate it after runner-host incidents, +and prefer narrowly scoped/fine-grained credentials where GitHub supports the +required runner APIs. + +Docker-capable runner planes mount the host Docker socket or Windows Docker +named pipe. Any repository allowed onto those runner groups can effectively +control the Docker host, so `repositoryAccess: all` requires an explicit +break-glass environment flag and should not be used for public or untrusted +repositories. diff --git a/config/lume-runners.yaml b/config/lume-runners.yaml index 6c3f905..44bf792 100644 --- a/config/lume-runners.yaml +++ b/config/lume-runners.yaml @@ -17,7 +17,7 @@ pool: diskSize: 80GB network: nat guestUser: lume - guestPassword: lume + guestPassword: ${LUME_GUEST_PASSWORD} guestRunnerRoot: /Users/lume/actions-runner guestWorkRoot: /Users/lume/actions-runner/_work runnerVersion: 2.333.0 diff --git a/docs/bootstrap/claude-environment.md b/docs/bootstrap/claude-environment.md index 27691b8..f41d138 100644 --- a/docs/bootstrap/claude-environment.md +++ b/docs/bootstrap/claude-environment.md @@ -9,7 +9,7 @@ ## Claude Code On The Web - Hosted entrypoint: `https://claude.ai/code` -- Repo: `OMT-Global/synology-github-runner` +- Repo: `OMT-Global/github-runner-fleet` - Setup script: `bash scripts/claude-cloud/setup.sh` - Network access: start with limited access; only expand it when a task truly needs more than registries and GitHub - Environment variables: configure them in the Claude environment UI as `.env`-style key-value pairs @@ -52,5 +52,5 @@ ## Project - - Repository: `OMT-Global/synology-github-runner` + - Repository: `OMT-Global/github-runner-fleet` - Default branch: `main` diff --git a/docs/bootstrap/codex-cloud-environment.md b/docs/bootstrap/codex-cloud-environment.md index 34f481a..130583f 100644 --- a/docs/bootstrap/codex-cloud-environment.md +++ b/docs/bootstrap/codex-cloud-environment.md @@ -2,7 +2,7 @@ Configure the Codex Web environment in Codex settings for: -- Repo: `OMT-Global/synology-github-runner` +- Repo: `OMT-Global/github-runner-fleet` - Base image: `universal` - Setup mode: manual setup script - Setup script: `bash scripts/codex-cloud/setup.sh` diff --git a/docs/bootstrap/onboarding.md b/docs/bootstrap/onboarding.md index ca169aa..fb5dc67 100644 --- a/docs/bootstrap/onboarding.md +++ b/docs/bootstrap/onboarding.md @@ -2,7 +2,7 @@ ## Repo Governance -- Confirm the repository exists at `OMT-Global/synology-github-runner`. +- Confirm the repository exists at `OMT-Global/github-runner-fleet`. - Confirm branch protection or rulesets on `main` require one approval and code owner review. - Confirm branch protection points at the `CI Gate` status. - Confirm `delete branch on merge` and `allow auto-merge` are enabled. diff --git a/docs/workflow-cookbook.md b/docs/workflow-cookbook.md index c45cb37..8c1351b 100644 --- a/docs/workflow-cookbook.md +++ b/docs/workflow-cookbook.md @@ -1,30 +1,32 @@ # Shell-safe workflow cookbook -This guide is for downstream repositories that want to consume the runner contracts from this repo without guessing which jobs belong on self-hosted Synology runners, which jobs belong on the Lume macOS pool, and which jobs should stay on GitHub-hosted runners. +This guide is for downstream repositories that want to consume the runner contracts from this repo without guessing which jobs belong on self-hosted Synology runners, Linux Docker runners, Windows Docker runners, the Lume macOS pool, and GitHub-hosted runners. ## Runner compatibility matrix -| Job class | Synology shell-only pool | Lume macOS pool | GitHub-hosted runners | Notes | -| --- | --- | --- | --- | --- | -| Node install, lint, test, build | Yes | Usually unnecessary | Yes | On Synology, use `OMT-Global/synology-github-runner/actions/setup-shell-safe-node` instead of `actions/setup-node`. | -| Python 3.12 lint/test | Yes | Optional | Yes | `actions/setup-python@v6` with `python-version: '3.12'` resolves locally on the Synology image. Other Python versions should stay hosted unless you control the full toolchain. | -| Terraform fmt/validate/init without cloud sidecars | Yes | Optional | Yes | Keep plugin cache under `RUNNER_TEMP` or another writable container-local path. | -| Docs checks, markdown lint, shell validation | Yes | Optional | Yes | Good fit for the shell-only pool when the job only needs baked-in CLI tools. | -| Release image builds, Buildx, QEMU, registry publish | No | No | Yes | Keep these on GitHub-hosted runners. | -| Docker daemon, `docker build`, `docker compose`, service containers | No | No | Yes | The Synology runner class intentionally avoids Docker socket mounts and does not support service containers. | -| `container:` jobs | No | No | Yes | Route these back to GitHub-hosted runners. | -| Browser/UI/E2E jobs needing extra distro packages | No | Sometimes | Yes | Prefer hosted runners unless the macOS requirement is explicit and owned. | -| macOS signing, Xcode builds, Swift/macOS validation | No | Yes | Yes | Use the Lume pool when you need a self-hosted macOS environment. | -| Public fork pull requests | No | No | Yes | Keep fork PRs on GitHub-hosted runners so untrusted code does not land on self-hosted infrastructure. | +| Job class | Synology shell-only pool | Linux Docker pool | Windows Docker pool | Lume macOS pool | GitHub-hosted runners | Notes | +| --- | --- | --- | --- | --- | --- | --- | +| Node install, lint, test, build | Yes | Yes | Case-by-case | Usually unnecessary | Yes | On Synology, use `OMT-Global/github-runner-fleet/actions/setup-shell-safe-node` instead of `actions/setup-node`. | +| Python 3.12 lint/test | Yes | Yes | Case-by-case | Optional | Yes | `actions/setup-python@v6` with `python-version: '3.12'` resolves locally on the Synology image. Other Python versions should stay hosted unless you control the full toolchain. | +| Terraform fmt/validate/init without cloud sidecars | Yes | Yes | Case-by-case | Optional | Yes | Keep plugin cache under `RUNNER_TEMP` or another writable container-local path. | +| Docs checks, markdown lint, shell validation | Yes | Yes | Case-by-case | Optional | Yes | Good fit for the shell-only pool when the job only needs baked-in CLI tools. | +| Release image builds, Buildx, QEMU, registry publish | No | Yes | No | No | Yes | Use Linux Docker for trusted private workloads; keep untrusted or public fork builds hosted. | +| Docker daemon, `docker build`, `docker compose`, service containers | No | Yes | Windows containers only | No | Yes | The Synology runner class intentionally avoids Docker socket mounts and does not support service containers. | +| `container:` jobs | No | Yes | Windows containers only | No | Yes | Use Docker-capable private planes for trusted repos; use hosted runners for untrusted code. | +| Browser/UI/E2E jobs needing extra distro packages | No | Case-by-case | Case-by-case | Sometimes | Yes | Prefer hosted runners unless the self-hosted requirement is explicit and owned. | +| macOS signing, Xcode builds, Swift/macOS validation | No | No | No | Yes | Yes | Use the Lume pool when you need a self-hosted macOS environment. | +| Public fork pull requests | No | No | No | No | Yes | Keep fork PRs on GitHub-hosted runners so untrusted code does not land on self-hosted infrastructure. | ## Routing rules Use these rules when deciding where a workflow job should run: - Use `runs-on: [self-hosted, synology, shell-only, public]` for trusted shell-safe jobs that can run with the baked-in Linux toolchain. +- Use `runs-on: [self-hosted, linux, docker-capable, private]` for trusted private Linux jobs that need Docker, `container:`, or service containers. +- Use `runs-on: [self-hosted, windows, docker-capable, private]` only for trusted private Windows container work. - Use `runs-on: [self-hosted, macos, arm64]` only when you intentionally target the Lume macOS pool and control the repo trust boundary. - Keep pull requests from forks on GitHub-hosted runners. -- Keep any workflow using `container:`, `services:`, browsers, Docker daemon access, Buildx, or extra distro package assumptions on GitHub-hosted runners. +- Keep any untrusted workflow using `container:`, `services:`, browsers, Docker daemon access, Buildx, or extra distro package assumptions on GitHub-hosted runners. - Prefer a split workflow over forcing one runner class to handle incompatible jobs. ## Recipe: trusted Node job on the Synology shell-only pool @@ -57,7 +59,7 @@ jobs: - uses: pnpm/action-setup@v5 with: version: 10.32.1 - - uses: OMT-Global/synology-github-runner/actions/setup-shell-safe-node@main + - uses: OMT-Global/github-runner-fleet/actions/setup-shell-safe-node@main with: node-version: 24.14.1 - run: pnpm install --frozen-lockfile @@ -100,7 +102,7 @@ jobs: - uses: pnpm/action-setup@v5 with: version: 10.32.1 - - uses: OMT-Global/synology-github-runner/actions/setup-shell-safe-node@main + - uses: OMT-Global/github-runner-fleet/actions/setup-shell-safe-node@main with: node-version: 24.14.1 - run: pnpm install --frozen-lockfile @@ -180,7 +182,7 @@ jobs: - run: terraform validate ``` -This works well for pure CLI Terraform jobs. If the workflow also builds containers, talks to Docker, or needs sidecar services, split those parts back to GitHub-hosted runners. +This works well for pure CLI Terraform jobs. If the workflow also builds containers, talks to Docker, or needs sidecar services, split those parts onto the Linux Docker private plane for trusted repos or back to GitHub-hosted runners for untrusted code. ## Recipe: Lume macOS contract job @@ -213,9 +215,9 @@ Use the hosted `macos-latest` image instead when the repository does not need se ## Force jobs back to GitHub-hosted runners when -- the workflow uses `container:` -- the workflow uses `services:` -- the job requires Docker daemon access, Buildx, or QEMU +- the workflow uses `container:` and is not trusted private work assigned to a Docker-capable plane +- the workflow uses `services:` and is not trusted private work assigned to a Docker-capable plane +- the job requires Docker daemon access, Buildx, or QEMU and is not trusted private Linux Docker work - the job needs browsers or large sets of distro packages not already present in the runner contract - the change comes from a public fork or another untrusted source - the job depends on a language/version combination outside the documented self-hosted contract diff --git a/project.bootstrap.yaml b/project.bootstrap.yaml index b572198..aa98567 100644 --- a/project.bootstrap.yaml +++ b/project.bootstrap.yaml @@ -1,7 +1,7 @@ version: 1 project: - name: synology-github-runner - description: Shell-only GitHub self-hosted runner pools for Synology NAS deployments. + name: github-runner-fleet + description: Self-hosted GitHub runner fleet for Synology, Linux Docker, Windows Docker, and Lume macOS planes. visibility: public owner: OMT-Global defaultBranch: main @@ -24,7 +24,7 @@ repo: archetype: kind: generic-empty packageManager: npm - moduleName: synology_github_runner + moduleName: github_runner_fleet github: createRepo: false reviewers: diff --git a/scripts/smoke/mock-api.mjs b/scripts/smoke/mock-api.mjs index 5b2f98c..4533c08 100644 --- a/scripts/smoke/mock-api.mjs +++ b/scripts/smoke/mock-api.mjs @@ -4,6 +4,7 @@ import path from "node:path"; const logPath = process.env.MOCK_LOG_PATH ?? "/tmp/mock-api.log"; const port = Number(process.env.MOCK_PORT ?? "8080"); +const host = process.env.MOCK_HOST ?? "0.0.0.0"; fs.mkdirSync(path.dirname(logPath), { recursive: true }); const server = http.createServer((req, res) => { @@ -29,10 +30,10 @@ const server = http.createServer((req, res) => { res.end(JSON.stringify({ error: "not found" })); }); -server.listen(port, "0.0.0.0", () => { +server.listen(port, host, () => { fs.appendFileSync( logPath, - `${new Date().toISOString()} listening 0.0.0.0:${port}\n`, + `${new Date().toISOString()} listening ${host}:${port}\n`, "utf8" ); }); diff --git a/src/cli.ts b/src/cli.ts index d01710c..8a2f40e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -234,6 +234,8 @@ async function doctorCommand(args: string[]): Promise { mode, envPath: getOption(args, "--env", ".env"), configPath: getOption(args, "--config", "config/pools.yaml"), + linuxConfigPath: getOption(args, "--linux-config", "config/linux-docker-runners.yaml"), + windowsConfigPath: getOption(args, "--windows-config", "config/windows-runners.yaml"), lumeConfigPath: getOption(args, "--lume-config", "config/lume-runners.yaml") }); @@ -2321,7 +2323,13 @@ function getDoctorMode(args: string[]): DoctorMode { continue; } - if (arg === "full" || arg === "synology" || arg === "lume") { + if ( + arg === "full" || + arg === "synology" || + arg === "linux-docker" || + arg === "windows-docker" || + arg === "lume" + ) { return arg; } @@ -2671,7 +2679,7 @@ function powerShellQuote(value: string): string { function printUsage(): void { process.stderr.write(`Usage: - pnpm doctor [full|synology|lume] [--env .env] [--config config/pools.yaml] [--lume-config config/lume-runners.yaml] [--format text|json] + pnpm doctor [full|synology|linux-docker|windows-docker|lume] [--env .env] [--config config/pools.yaml] [--linux-config config/linux-docker-runners.yaml] [--windows-config config/windows-runners.yaml] [--lume-config config/lume-runners.yaml] [--format text|json] pnpm audit-log [--file /var/log/runner-fleet/audit.jsonl] [--max-size-bytes 10485760] < event.json pnpm drift-detect [--config config/pools.yaml] [--env .env] [--threshold 0] pnpm config-diff [--plane synology|linux-docker|windows-docker|lume] [--env .env] [--config config/pools.yaml] [--linux-config config/linux-docker-runners.yaml] [--windows-config config/windows-runners.yaml] [--lume-config config/lume-runners.yaml] [--format text|json] diff --git a/src/lib/compose.ts b/src/lib/compose.ts index c9d44fe..09e8d7a 100644 --- a/src/lib/compose.ts +++ b/src/lib/compose.ts @@ -1,6 +1,7 @@ import YAML from "yaml"; import type { DeploymentEnv } from "./env.js"; import type { PoolConfig, ResolvedConfig } from "./config.js"; +import { buildCommonRunnerEnv } from "./runner-plane.js"; export function renderCompose( config: ResolvedConfig, @@ -41,27 +42,21 @@ export function buildRunnerStateDir(pool: PoolConfig, index: number): string { function renderService(pool: PoolConfig, index: number): Record { const runnerStateDir = buildRunnerStateDir(pool, index); - const environment: Record = { - GITHUB_PAT: "${GITHUB_PAT}", - GITHUB_API_URL: "${GITHUB_API_URL:-https://api.github.com}", - GITHUB_ORG: pool.organization, - RUNNER_SCOPE: "organization", - RUNNER_NAME: buildRunnerName(pool, index), - RUNNER_GROUP: pool.runnerGroup, - FLEET_POOL_KEY: pool.key, - FLEET_PLANE: "synology", - RUNNER_LABELS: pool.labels.join(","), - RUNNER_VISIBILITY: pool.visibility, - RUNNER_REPOSITORY_ACCESS: pool.repositoryAccess, - RUNNER_STATE_DIR: runnerStateDir, - RUNNER_LOG_DIR: `${runnerStateDir}/logs`, - RUNNER_WORK_DIR: "/tmp/github-runner-work", - RUNNER_TEMP: "/tmp/github-runner-temp", - RUNNER_TOOL_CACHE: "/opt/hostedtoolcache", - AGENT_TOOLSDIRECTORY: "/opt/hostedtoolcache", - RUNNER_EPHEMERAL: "true", - RUNNER_DISABLE_UPDATE: "true" - }; + const environment = buildCommonRunnerEnv({ + organization: pool.organization, + runnerName: buildRunnerName(pool, index), + runnerGroup: pool.runnerGroup, + poolKey: pool.key, + plane: "synology", + labels: pool.labels, + visibility: pool.visibility, + repositoryAccess: pool.repositoryAccess, + runnerStateDir, + runnerLogDir: `${runnerStateDir}/logs`, + runnerWorkDir: "/tmp/github-runner-work", + runnerTemp: "/tmp/github-runner-temp", + runnerToolCache: "/opt/hostedtoolcache" + }); if (pool.repositoryAccess === "selected") { environment.RUNNER_ALLOWED_REPOSITORIES = pool.allowedRepositories.join(","); diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index e6dad7d..6bc48b9 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -2,6 +2,8 @@ import fs from "node:fs"; import { auditLogFileFromEnv } from "./audit.js"; import { collectConfigWarnings, loadConfig } from "./config.js"; import { loadDeploymentEnv } from "./env.js"; +import { loadLinuxDockerConfig } from "./linux-docker-config.js"; +import { loadWindowsDockerConfig } from "./windows-config.js"; import { type FetchLike, verifyContainerImageTag, @@ -20,12 +22,13 @@ import { type MetricSample } from "./metrics.js"; -export type DoctorMode = "full" | "synology" | "lume"; +export type DoctorMode = "full" | "synology" | "linux-docker" | "windows-docker" | "lume"; export type DoctorCheckStatus = "pass" | "warn" | "fail" | "skip"; +export type DoctorTarget = "synology" | "linux-docker" | "windows-docker" | "lume"; export interface DoctorCheck { id: string; - target: "synology" | "lume"; + target: DoctorTarget; status: DoctorCheckStatus; summary: string; detail?: string; @@ -42,6 +45,8 @@ export interface RunDoctorOptions { mode?: DoctorMode; envPath?: string; configPath?: string; + linuxConfigPath?: string; + windowsConfigPath?: string; lumeConfigPath?: string; fetchImpl?: FetchLike; } @@ -52,6 +57,10 @@ export async function runDoctor( const mode = options.mode ?? "full"; const envPath = options.envPath ?? ".env"; const configPath = options.configPath ?? "config/pools.yaml"; + const linuxConfigPath = + options.linuxConfigPath ?? "config/linux-docker-runners.yaml"; + const windowsConfigPath = + options.windowsConfigPath ?? "config/windows-runners.yaml"; const lumeConfigPath = options.lumeConfigPath ?? "config/lume-runners.yaml"; const fetchImpl = options.fetchImpl; const env = loadDeploymentEnv({ @@ -69,6 +78,24 @@ export async function runDoctor( checks.push(...synologyChecks); } + if (mode === "full" || mode === "linux-docker") { + const linuxDockerChecks = await runLinuxDockerDoctor({ + env, + configPath: linuxConfigPath, + fetchImpl + }); + checks.push(...linuxDockerChecks); + } + + if (mode === "full" || mode === "windows-docker") { + const windowsDockerChecks = await runWindowsDockerDoctor({ + env, + configPath: windowsConfigPath, + fetchImpl + }); + checks.push(...windowsDockerChecks); + } + if (mode === "full" || mode === "lume") { const lumeChecks = await runLumeDoctor({ env, @@ -88,6 +115,211 @@ export async function runDoctor( return report; } +async function runLinuxDockerDoctor(input: { + env: ReturnType; + configPath: string; + fetchImpl?: FetchLike; +}): Promise { + const checks: DoctorCheck[] = []; + const missingDeploymentEnv = [ + ["GITHUB_PAT", input.env.githubPat], + ["LINUX_DOCKER_HOST", input.env.linuxDockerHost], + ["LINUX_DOCKER_USERNAME", input.env.linuxDockerUsername] + ] + .filter(([, value]) => !value) + .map(([key]) => key); + + checks.push( + missingDeploymentEnv.length === 0 + ? { + id: "linux-docker-env", + target: "linux-docker", + status: "pass", + summary: "required Linux Docker deployment env is configured" + } + : { + id: "linux-docker-env", + target: "linux-docker", + status: "fail", + summary: "required Linux Docker deployment env is incomplete", + detail: `missing ${missingDeploymentEnv.join(", ")}` + } + ); + + let config: ReturnType | undefined; + try { + config = loadLinuxDockerConfig(input.configPath, input.env); + checks.push({ + id: "linux-docker-config", + target: "linux-docker", + status: "pass", + summary: `loaded ${input.configPath} with ${config.pools.length} pool${config.pools.length === 1 ? "" : "s"}`, + data: { + pools: config.pools.map((pool) => ({ + key: pool.key, + size: pool.size + })) + } + }); + } catch (error) { + checks.push({ + id: "linux-docker-config", + target: "linux-docker", + status: "fail", + summary: `failed to load ${input.configPath}`, + detail: formatError(error) + }); + return checks; + } + + if (!input.env.githubPat) { + checks.push({ + id: "linux-docker-runner-groups", + target: "linux-docker", + status: "skip", + summary: "skipped Linux Docker runner-group verification", + detail: "GITHUB_PAT is not configured" + }); + return checks; + } + + try { + const pools = await verifyRunnerGroups( + input.env.githubApiUrl, + input.env.githubPat, + config.pools.map((pool) => ({ + poolKey: pool.key, + organization: pool.organization, + runnerGroup: pool.runnerGroup + })), + input.fetchImpl + ); + checks.push({ + id: "linux-docker-runner-groups", + target: "linux-docker", + status: "pass", + summary: `verified ${pools.length} Linux Docker runner group${pools.length === 1 ? "" : "s"} in GitHub` + }); + } catch (error) { + checks.push({ + id: "linux-docker-runner-groups", + target: "linux-docker", + status: "fail", + summary: "failed Linux Docker runner-group verification", + detail: formatError(error) + }); + } + + return checks; +} + +async function runWindowsDockerDoctor(input: { + env: ReturnType; + configPath: string; + fetchImpl?: FetchLike; +}): Promise { + const checks: DoctorCheck[] = []; + const missingDeploymentEnv = [ + ["GITHUB_PAT", input.env.githubPat] + ] + .filter(([, value]) => !value) + .map(([key]) => key); + + checks.push( + missingDeploymentEnv.length === 0 + ? { + id: "windows-docker-env", + target: "windows-docker", + status: "pass", + summary: "required Windows Docker GitHub env is configured" + } + : { + id: "windows-docker-env", + target: "windows-docker", + status: "fail", + summary: "required Windows Docker GitHub env is incomplete", + detail: `missing ${missingDeploymentEnv.join(", ")}` + } + ); + + let config: ReturnType | undefined; + try { + config = loadWindowsDockerConfig(input.configPath, input.env); + const missingHostFields = config.pools.flatMap((pool) => [ + ...(pool.host ? [] : [`${pool.key}:host`]), + ...(pool.sshUser ? [] : [`${pool.key}:sshUser`]) + ]); + checks.push({ + id: "windows-docker-config", + target: "windows-docker", + status: missingHostFields.length === 0 ? "pass" : "fail", + summary: + missingHostFields.length === 0 + ? `loaded ${input.configPath} with ${config.pools.length} pool${config.pools.length === 1 ? "" : "s"}` + : "Windows Docker config is missing target host fields", + detail: + missingHostFields.length === 0 + ? undefined + : `missing ${missingHostFields.join(", ")}`, + data: { + pools: config.pools.map((pool) => ({ + key: pool.key, + size: pool.size + })) + } + }); + } catch (error) { + checks.push({ + id: "windows-docker-config", + target: "windows-docker", + status: "fail", + summary: `failed to load ${input.configPath}`, + detail: formatError(error) + }); + return checks; + } + + if (!input.env.githubPat) { + checks.push({ + id: "windows-docker-runner-groups", + target: "windows-docker", + status: "skip", + summary: "skipped Windows Docker runner-group verification", + detail: "GITHUB_PAT is not configured" + }); + return checks; + } + + try { + const pools = await verifyRunnerGroups( + input.env.githubApiUrl, + input.env.githubPat, + config.pools.map((pool) => ({ + poolKey: pool.key, + organization: pool.organization, + runnerGroup: pool.runnerGroup + })), + input.fetchImpl + ); + checks.push({ + id: "windows-docker-runner-groups", + target: "windows-docker", + status: "pass", + summary: `verified ${pools.length} Windows Docker runner group${pools.length === 1 ? "" : "s"} in GitHub` + }); + } catch (error) { + checks.push({ + id: "windows-docker-runner-groups", + target: "windows-docker", + status: "fail", + summary: "failed Windows Docker runner-group verification", + detail: formatError(error) + }); + } + + return checks; +} + export function renderDoctorReport(report: DoctorReport): string { const lines = [`doctor mode: ${report.mode}`]; @@ -481,10 +713,10 @@ function levelForStatus(status: DoctorCheckStatus): LogLevel { } function poolSlotMetricsForCheck(check: DoctorCheck): MetricSample[] { - if (check.target === "synology" && isSynologyConfigData(check.data)) { + if (isPoolConfigData(check.data)) { return check.data.pools.map((pool) => poolSlotCount({ - plane: "synology", + plane: check.target, pool: pool.key, count: pool.size }) @@ -504,7 +736,7 @@ function poolSlotMetricsForCheck(check: DoctorCheck): MetricSample[] { return []; } -function isSynologyConfigData( +function isPoolConfigData( value: unknown ): value is { pools: Array<{ key: string; size: number }> } { return ( diff --git a/src/lib/linux-docker-compose.ts b/src/lib/linux-docker-compose.ts index 731ec2a..969950c 100644 --- a/src/lib/linux-docker-compose.ts +++ b/src/lib/linux-docker-compose.ts @@ -4,6 +4,7 @@ import type { ResolvedLinuxDockerConfig } from "./linux-docker-config.js"; import type { DeploymentEnv } from "./env.js"; +import { buildCommonRunnerEnv } from "./runner-plane.js"; export function renderLinuxDockerCompose( config: ResolvedLinuxDockerConfig, @@ -53,26 +54,22 @@ function renderService( const runnerTempDir = `${runnerStateDir}/_temp`; const runnerToolCache = `${runnerStateDir}/toolcache`; const environment: Record = { - GITHUB_PAT: "${GITHUB_PAT}", - GITHUB_API_URL: "${GITHUB_API_URL:-https://api.github.com}", - GITHUB_ORG: pool.organization, - RUNNER_SCOPE: "organization", - RUNNER_NAME: buildLinuxDockerServiceName(pool, index), - RUNNER_GROUP: pool.runnerGroup, - FLEET_POOL_KEY: pool.key, - FLEET_PLANE: "linux-docker", - RUNNER_LABELS: pool.labels.join(","), - RUNNER_VISIBILITY: pool.visibility, - RUNNER_REPOSITORY_ACCESS: pool.repositoryAccess, - RUNNER_STATE_DIR: runnerStateDir, - RUNNER_LOG_DIR: `${runnerStateDir}/logs`, - RUNNER_WORK_DIR: runnerWorkDir, - RUNNER_TEMP: runnerTempDir, - RUNNER_TOOL_CACHE: runnerToolCache, - AGENT_TOOLSDIRECTORY: runnerToolCache, + ...buildCommonRunnerEnv({ + organization: pool.organization, + runnerName: buildLinuxDockerServiceName(pool, index), + runnerGroup: pool.runnerGroup, + poolKey: pool.key, + plane: "linux-docker", + labels: pool.labels, + visibility: pool.visibility, + repositoryAccess: pool.repositoryAccess, + runnerStateDir, + runnerLogDir: `${runnerStateDir}/logs`, + runnerWorkDir, + runnerTemp: runnerTempDir, + runnerToolCache + }), RUNNER_EXEC_MODE_OVERRIDE: "root", - RUNNER_EPHEMERAL: "true", - RUNNER_DISABLE_UPDATE: "true", DOCKER_HOST: "unix:///var/run/docker.sock" }; diff --git a/src/lib/linux-docker-config.ts b/src/lib/linux-docker-config.ts index a46e221..b3178e3 100644 --- a/src/lib/linux-docker-config.ts +++ b/src/lib/linux-docker-config.ts @@ -8,6 +8,13 @@ import type { RunnerPlatform } from "./config.js"; import type { DeploymentEnv } from "./env.js"; +import { + interpolateEnv, + repositoryPattern, + uniqueRunnerLabels, + validateDockerRepositoryAccess, + validateRepositoryOwner +} from "./runner-plane.js"; export interface LinuxDockerPoolConfig { key: string; @@ -33,8 +40,6 @@ export interface ResolvedLinuxDockerConfig { pools: LinuxDockerPoolConfig[]; } -const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; - const poolSchema = z .object({ key: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), @@ -92,7 +97,7 @@ export function loadLinuxDockerConfig( const absolutePath = path.resolve(configPath); const source = fs.readFileSync(absolutePath, "utf8"); const parsed = YAML.parse(source); - const interpolated = interpolate(parsed, env.raw); + const interpolated = interpolateEnv(parsed, env.raw); const result = configSchema.parse(interpolated); const seenKeys = new Set(); @@ -103,15 +108,19 @@ export function loadLinuxDockerConfig( seenKeys.add(pool.key); if (pool.repositoryAccess === "selected") { - for (const repository of pool.allowedRepositories) { - const [owner] = repository.split("/"); - if (owner !== pool.organization) { - throw new Error( - `linux-docker pool ${pool.key} includes ${repository}, which is outside organization ${pool.organization}` - ); - } - } + validateRepositoryOwner({ + plane: "linux-docker", + poolKey: pool.key, + organization: pool.organization, + repositories: pool.allowedRepositories + }); } + validateDockerRepositoryAccess({ + plane: "linux-docker", + poolKey: pool.key, + repositoryAccess: pool.repositoryAccess, + env: env.raw + }); if (!path.isAbsolute(pool.runnerRoot)) { throw new Error( @@ -122,7 +131,10 @@ export function loadLinuxDockerConfig( return { ...pool, visibility: "private" as const, - labels: uniqueLabels(pool.labels), + labels: uniqueRunnerLabels( + ["linux", "docker-capable", "private"], + pool.labels + ), resources: { cpus: pool.resources.cpus, memory: pool.resources.memory, @@ -138,40 +150,3 @@ export function loadLinuxDockerConfig( pools }; } - -function uniqueLabels(labels: string[]): string[] { - return [...new Set(["linux", "docker-capable", "private", ...labels])]; -} - -function interpolate(value: unknown, env: Record): unknown { - if (typeof value === "string") { - return value.replace( - /\$\{([A-Z0-9_]+)(?::-(.*?))?\}/g, - (_match, name: string, defaultValue?: string) => { - const envValue = env[name]; - if (envValue !== undefined) { - return envValue; - } - if (defaultValue !== undefined) { - return defaultValue; - } - throw new Error(`missing environment value for ${name}`); - } - ); - } - - if (Array.isArray(value)) { - return value.map((item) => interpolate(item, env)); - } - - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value).map(([key, nestedValue]) => [ - key, - interpolate(nestedValue, env) - ]) - ); - } - - return value; -} diff --git a/src/lib/lume-config.ts b/src/lib/lume-config.ts index 93f2d88..76c002c 100644 --- a/src/lib/lume-config.ts +++ b/src/lib/lume-config.ts @@ -3,6 +3,7 @@ import path from "node:path"; import YAML from "yaml"; import { z } from "zod"; import type { DeploymentEnv } from "./env.js"; +import { interpolateEnv, uniqueRunnerLabels } from "./runner-plane.js"; export interface LumePoolConfig { key: string; @@ -70,7 +71,7 @@ const poolSchema = z.object({ network: z.string().min(1).default("nat"), storage: z.string().min(1).optional(), guestUser: z.string().min(1).default("lume"), - guestPassword: z.string().min(1).default("lume"), + guestPassword: z.string().min(1).optional(), guestRunnerRoot: z.string().min(1).default("/Users/lume/actions-runner"), guestWorkRoot: z.string().min(1).default("/Users/lume/actions-runner/_work"), runnerVersion: z.string().min(1).optional() @@ -88,7 +89,7 @@ export function loadLumeConfig( const absolutePath = path.resolve(configPath); const source = fs.readFileSync(absolutePath, "utf8"); const parsed = YAML.parse(source); - const interpolated = interpolate(parsed, env.raw); + const interpolated = interpolateEnv(parsed, env.raw); const result = configSchema.parse(interpolated); if (!path.isAbsolute(env.lumeRunnerBaseDir)) { @@ -100,8 +101,16 @@ export function loadLumeConfig( } const normalizedLabels = normalizeLabels(result.pool.labels); + const guestPassword = + result.pool.guestPassword ?? env.raw.LUME_GUEST_PASSWORD?.trim(); + if (!guestPassword) { + throw new Error( + "Lume guestPassword must be set in config or LUME_GUEST_PASSWORD" + ); + } const pool: LumePoolConfig = { ...result.pool, + guestPassword, labels: normalizedLabels, runnerVersion: result.pool.runnerVersion ?? env.runnerVersion }; @@ -206,40 +215,7 @@ function buildSlots(pool: LumePoolConfig, baseDir: string): LumeSlotManifest[] { } function normalizeLabels(labels: string[]): string[] { - return [...new Set(["self-hosted", "macos", "arm64", "private", ...labels])]; -} - -function interpolate(value: unknown, env: Record): unknown { - if (typeof value === "string") { - return value.replace( - /\$\{([A-Z0-9_]+)(?::-(.*?))?\}/g, - (_match, name: string, defaultValue?: string) => { - const envValue = env[name]; - if (envValue !== undefined) { - return envValue; - } - if (defaultValue !== undefined) { - return defaultValue; - } - throw new Error(`missing environment value for ${name}`); - } - ); - } - - if (Array.isArray(value)) { - return value.map((item) => interpolate(item, env)); - } - - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value).map(([key, nestedValue]) => [ - key, - interpolate(nestedValue, env) - ]) - ); - } - - return value; + return uniqueRunnerLabels(["self-hosted", "macos", "arm64", "private"], labels); } function shellQuote(value: string): string { diff --git a/src/lib/runner-plane.ts b/src/lib/runner-plane.ts new file mode 100644 index 0000000..d7dc57b --- /dev/null +++ b/src/lib/runner-plane.ts @@ -0,0 +1,129 @@ +import type { RepositoryAccess } from "./config.js"; + +export type DockerCapablePlane = "linux-docker" | "windows-docker"; +export type RunnerPlane = "synology" | DockerCapablePlane; + +const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; + +export function repositoryAccessSchemaValues(): ["all", "selected"] { + return ["all", "selected"]; +} + +export function validateRepositoryOwner(input: { + plane: string; + poolKey: string; + organization: string; + repositories: string[]; +}): void { + for (const repository of input.repositories) { + const [owner] = repository.split("/"); + if (owner !== input.organization) { + throw new Error( + `${input.plane} pool ${input.poolKey} includes ${repository}, which is outside organization ${input.organization}` + ); + } + } +} + +export function validateDockerRepositoryAccess(input: { + plane: DockerCapablePlane; + poolKey: string; + repositoryAccess: RepositoryAccess; + env: Record; +}): void { + if (input.repositoryAccess !== "all") { + return; + } + + const flagName = + input.plane === "linux-docker" + ? "LINUX_DOCKER_ALLOW_ALL_REPOSITORIES" + : "WINDOWS_DOCKER_ALLOW_ALL_REPOSITORIES"; + if (input.env[flagName] === "true") { + return; + } + + throw new Error( + `${input.plane} pool ${input.poolKey} uses repositoryAccess=all; set ${flagName}=true to explicitly allow all repositories on Docker-capable runners` + ); +} + +export function uniqueRunnerLabels( + defaultLabels: string[], + labels: string[] +): string[] { + return [...new Set([...defaultLabels, ...labels])]; +} + +export function buildCommonRunnerEnv(input: { + organization: string; + runnerName: string; + runnerGroup: string; + poolKey: string; + plane: RunnerPlane; + labels: string[]; + visibility: string; + repositoryAccess: RepositoryAccess; + runnerStateDir: string; + runnerLogDir: string; + runnerWorkDir: string; + runnerTemp: string; + runnerToolCache: string; +}): Record { + return { + GITHUB_PAT: "${GITHUB_PAT}", + GITHUB_API_URL: "${GITHUB_API_URL:-https://api.github.com}", + GITHUB_ORG: input.organization, + RUNNER_SCOPE: "organization", + RUNNER_NAME: input.runnerName, + RUNNER_GROUP: input.runnerGroup, + FLEET_POOL_KEY: input.poolKey, + FLEET_PLANE: input.plane, + RUNNER_LABELS: input.labels.join(","), + RUNNER_VISIBILITY: input.visibility, + RUNNER_REPOSITORY_ACCESS: input.repositoryAccess, + RUNNER_STATE_DIR: input.runnerStateDir, + RUNNER_LOG_DIR: input.runnerLogDir, + RUNNER_WORK_DIR: input.runnerWorkDir, + RUNNER_TEMP: input.runnerTemp, + RUNNER_TOOL_CACHE: input.runnerToolCache, + AGENT_TOOLSDIRECTORY: input.runnerToolCache, + RUNNER_EPHEMERAL: "true", + RUNNER_DISABLE_UPDATE: "true" + }; +} + +export function interpolateEnv(value: unknown, env: Record): unknown { + if (typeof value === "string") { + return value.replace( + /\$\{([A-Z0-9_]+)(?::-(.*?))?\}/g, + (_match, name: string, defaultValue?: string) => { + const envValue = env[name]; + if (envValue !== undefined) { + return envValue; + } + if (defaultValue !== undefined) { + return defaultValue; + } + throw new Error(`missing environment value for ${name}`); + } + ); + } + + if (Array.isArray(value)) { + return value.map((item) => interpolateEnv(item, env)); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [ + key, + interpolateEnv(nestedValue, env) + ]) + ); + } + + return value; +} + +export { repositoryPattern }; diff --git a/src/lib/windows-compose.ts b/src/lib/windows-compose.ts index 9fedecf..af72413 100644 --- a/src/lib/windows-compose.ts +++ b/src/lib/windows-compose.ts @@ -5,6 +5,7 @@ import type { ResolvedWindowsDockerConfig, WindowsDockerPoolConfig } from "./windows-config.js"; +import { buildCommonRunnerEnv } from "./runner-plane.js"; export function renderWindowsDockerCompose( config: ResolvedWindowsDockerConfig, @@ -55,23 +56,21 @@ function renderService( const runnerToolCache = path.win32.join(runnerStateDir, "toolcache"); const serviceName = buildWindowsDockerServiceName(pool, index); const environment: Record = { - GITHUB_PAT: "${GITHUB_PAT}", - GITHUB_API_URL: "${GITHUB_API_URL:-https://api.github.com}", - GITHUB_ORG: pool.organization, - RUNNER_SCOPE: "organization", - RUNNER_NAME: serviceName, - RUNNER_GROUP: pool.runnerGroup, - RUNNER_LABELS: pool.labels.join(","), - RUNNER_VISIBILITY: pool.visibility, - RUNNER_REPOSITORY_ACCESS: pool.repositoryAccess, - RUNNER_STATE_DIR: runnerStateDir, - RUNNER_LOG_DIR: path.win32.join(runnerStateDir, "logs"), - RUNNER_WORK_DIR: runnerWorkDir, - RUNNER_TEMP: runnerTempDir, - RUNNER_TOOL_CACHE: runnerToolCache, - AGENT_TOOLSDIRECTORY: runnerToolCache, - RUNNER_EPHEMERAL: "true", - RUNNER_DISABLE_UPDATE: "true", + ...buildCommonRunnerEnv({ + organization: pool.organization, + runnerName: serviceName, + runnerGroup: pool.runnerGroup, + poolKey: pool.key, + plane: "windows-docker", + labels: pool.labels, + visibility: pool.visibility, + repositoryAccess: pool.repositoryAccess, + runnerStateDir, + runnerLogDir: path.win32.join(runnerStateDir, "logs"), + runnerWorkDir, + runnerTemp: runnerTempDir, + runnerToolCache + }), DOCKER_HOST: "npipe:////./pipe/docker_engine" }; diff --git a/src/lib/windows-config.ts b/src/lib/windows-config.ts index 1f9437a..2e37311 100644 --- a/src/lib/windows-config.ts +++ b/src/lib/windows-config.ts @@ -4,6 +4,13 @@ import YAML from "yaml"; import { z } from "zod"; import type { PoolResources, RepositoryAccess } from "./config.js"; import type { DeploymentEnv } from "./env.js"; +import { + interpolateEnv, + repositoryPattern, + uniqueRunnerLabels, + validateDockerRepositoryAccess, + validateRepositoryOwner +} from "./runner-plane.js"; export interface WindowsDockerPoolConfig { key: string; @@ -32,7 +39,6 @@ export interface ResolvedWindowsDockerConfig { pools: WindowsDockerPoolConfig[]; } -const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const windowsAbsolutePathPattern = /^[A-Za-z]:[\\/]/; const poolSchema = z @@ -119,7 +125,7 @@ export function loadWindowsDockerConfig( const absolutePath = path.resolve(configPath); const source = fs.readFileSync(absolutePath, "utf8"); const parsed = YAML.parse(source); - const interpolated = interpolate(parsed, env.raw); + const interpolated = interpolateEnv(parsed, env.raw); const result = configSchema.parse(interpolated); const seenKeys = new Set(); @@ -133,15 +139,19 @@ export function loadWindowsDockerConfig( const allowedRepositories = pool.allowedRepositories ?? pool.repositories ?? []; const organization = pool.organization ?? inferOrganization(key, allowedRepositories); if (pool.repositoryAccess === "selected") { - for (const repository of allowedRepositories) { - const [owner] = repository.split("/"); - if (owner !== organization) { - throw new Error( - `windows-docker pool ${key} includes ${repository}, which is outside organization ${organization}` - ); - } - } + validateRepositoryOwner({ + plane: "windows-docker", + poolKey: key, + organization, + repositories: allowedRepositories + }); } + validateDockerRepositoryAccess({ + plane: "windows-docker", + poolKey: key, + repositoryAccess: pool.repositoryAccess, + env: env.raw + }); const runnerRoot = path.win32.normalize( pool.runnerRoot ?? @@ -169,7 +179,10 @@ export function loadWindowsDockerConfig( runnerGroup: pool.runnerGroup ?? pool.group!, repositoryAccess: pool.repositoryAccess, allowedRepositories, - labels: uniqueLabels(pool.labels), + labels: uniqueRunnerLabels( + ["windows", "docker-capable", "private"], + pool.labels + ), size: pool.size ?? pool.slots ?? 1, host: pool.host ?? env.windowsDockerHost ?? "", sshUser: pool.sshUser ?? env.windowsDockerUsername ?? "", @@ -218,40 +231,3 @@ function validateSingleInstallHost(pools: WindowsDockerPoolConfig[]): void { } } } - -function uniqueLabels(labels: string[]): string[] { - return [...new Set(["windows", "docker-capable", "private", ...labels])]; -} - -function interpolate(value: unknown, env: Record): unknown { - if (typeof value === "string") { - return value.replace( - /\$\{([A-Z0-9_]+)(?::-(.*?))?\}/g, - (_match, name: string, defaultValue?: string) => { - const envValue = env[name]; - if (envValue !== undefined) { - return envValue; - } - if (defaultValue !== undefined) { - return defaultValue; - } - throw new Error(`missing environment value for ${name}`); - } - ); - } - - if (Array.isArray(value)) { - return value.map((item) => interpolate(item, env)); - } - - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value).map(([key, nestedValue]) => [ - key, - interpolate(nestedValue, env) - ]) - ); - } - - return value; -} diff --git a/test/__snapshots__/linux-docker-compose.snapshot.test.ts.snap b/test/__snapshots__/linux-docker-compose.snapshot.test.ts.snap index ad1c6bb..8159d97 100644 --- a/test/__snapshots__/linux-docker-compose.snapshot.test.ts.snap +++ b/test/__snapshots__/linux-docker-compose.snapshot.test.ts.snap @@ -29,9 +29,9 @@ services: RUNNER_TEMP: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-01/_temp RUNNER_TOOL_CACHE: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-01/toolcache AGENT_TOOLSDIRECTORY: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-01/toolcache - RUNNER_EXEC_MODE_OVERRIDE: root RUNNER_EPHEMERAL: "true" RUNNER_DISABLE_UPDATE: "true" + RUNNER_EXEC_MODE_OVERRIDE: root DOCKER_HOST: unix:///var/run/docker.sock RUNNER_ALLOWED_REPOSITORIES: example/private-app volumes: @@ -72,9 +72,9 @@ services: RUNNER_TEMP: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-02/_temp RUNNER_TOOL_CACHE: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-02/toolcache AGENT_TOOLSDIRECTORY: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-02/toolcache - RUNNER_EXEC_MODE_OVERRIDE: root RUNNER_EPHEMERAL: "true" RUNNER_DISABLE_UPDATE: "true" + RUNNER_EXEC_MODE_OVERRIDE: root DOCKER_HOST: unix:///var/run/docker.sock RUNNER_ALLOWED_REPOSITORIES: example/private-app volumes: diff --git a/test/__snapshots__/linux-docker-install.snapshot.test.ts.snap b/test/__snapshots__/linux-docker-install.snapshot.test.ts.snap index 67dcf39..9e9a482 100644 --- a/test/__snapshots__/linux-docker-install.snapshot.test.ts.snap +++ b/test/__snapshots__/linux-docker-install.snapshot.test.ts.snap @@ -30,9 +30,9 @@ services: RUNNER_TEMP: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-01/_temp RUNNER_TOOL_CACHE: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-01/toolcache AGENT_TOOLSDIRECTORY: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-01/toolcache - RUNNER_EXEC_MODE_OVERRIDE: root RUNNER_EPHEMERAL: "true" RUNNER_DISABLE_UPDATE: "true" + RUNNER_EXEC_MODE_OVERRIDE: root DOCKER_HOST: unix:///var/run/docker.sock RUNNER_ALLOWED_REPOSITORIES: example/private-app volumes: @@ -73,9 +73,9 @@ services: RUNNER_TEMP: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-02/_temp RUNNER_TOOL_CACHE: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-02/toolcache AGENT_TOOLSDIRECTORY: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-02/toolcache - RUNNER_EXEC_MODE_OVERRIDE: root RUNNER_EPHEMERAL: "true" RUNNER_DISABLE_UPDATE: "true" + RUNNER_EXEC_MODE_OVERRIDE: root DOCKER_HOST: unix:///var/run/docker.sock RUNNER_ALLOWED_REPOSITORIES: example/private-app volumes: diff --git a/test/__snapshots__/windows-install.snapshot.test.ts.snap b/test/__snapshots__/windows-install.snapshot.test.ts.snap index fab2c55..62e60d9 100644 --- a/test/__snapshots__/windows-install.snapshot.test.ts.snap +++ b/test/__snapshots__/windows-install.snapshot.test.ts.snap @@ -19,6 +19,8 @@ services: RUNNER_SCOPE: organization RUNNER_NAME: windows-private-runner-01 RUNNER_GROUP: windows-private + FLEET_POOL_KEY: windows-private + FLEET_PLANE: windows-docker RUNNER_LABELS: windows,docker-capable,private,x64 RUNNER_VISIBILITY: private RUNNER_REPOSITORY_ACCESS: selected @@ -60,6 +62,8 @@ services: RUNNER_SCOPE: organization RUNNER_NAME: windows-private-runner-02 RUNNER_GROUP: windows-private + FLEET_POOL_KEY: windows-private + FLEET_PLANE: windows-docker RUNNER_LABELS: windows,docker-capable,private,x64 RUNNER_VISIBILITY: private RUNNER_REPOSITORY_ACCESS: selected diff --git a/test/cli.test.ts b/test/cli.test.ts index 3d1c7b0..5c10453 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -2123,6 +2123,7 @@ WINDOWS_DOCKER_USERNAME=administrator WINDOWS_DOCKER_PROJECT_DIR=C:\\github-runner-fleet\\windows-docker LUME_RUNNER_BASE_DIR=${directory}/lume LUME_RUNNER_ENV_FILE=${directory}/lume/runner.env +LUME_GUEST_PASSWORD=secret RUNNER_VERSION=2.333.0 COMPOSE_PROJECT_NAME=github-runner-fleet-test `, diff --git a/test/compose.test.ts b/test/compose.test.ts index 9d7ae2c..dea3bac 100644 --- a/test/compose.test.ts +++ b/test/compose.test.ts @@ -39,11 +39,16 @@ describe("renderCompose", () => { ]); expect(privateService.security_opt).toEqual(["no-new-privileges:true"]); expect(privateService.cap_drop).toEqual(["ALL"]); + expect(privateService).not.toHaveProperty("ports"); + expect(privateService).not.toHaveProperty("privileged"); + expect(JSON.stringify(privateService.volumes)).not.toContain( + "/var/run/docker.sock" + ); expect(privateService).not.toHaveProperty("init"); expect(privateService).not.toHaveProperty("platform"); expect(privateService).not.toHaveProperty("cpus"); expect(privateService).not.toHaveProperty("pids_limit"); - expect(JSON.stringify(privateService)).not.toContain("/var/run/docker.sock"); + expect(JSON.stringify(privateService)).not.toContain("docker_engine"); const publicService = payload.services["synology-public-runner-01"]; expect(publicService.environment).toMatchObject({ diff --git a/test/doctor.test.ts b/test/doctor.test.ts index 048f50c..716108f 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -60,8 +60,15 @@ SYNOLOGY_HOST=nas.example.com SYNOLOGY_USERNAME=admin SYNOLOGY_PASSWORD=secret SYNOLOGY_RUNNER_BASE_DIR=${directory}/synology +LINUX_DOCKER_HOST=docker-host.example.com +LINUX_DOCKER_USERNAME=runner +LINUX_DOCKER_RUNNER_BASE_DIR=${directory}/linux-docker +WINDOWS_DOCKER_HOST=windows-host.example.com +WINDOWS_DOCKER_USERNAME=administrator +WINDOWS_DOCKER_RUNNER_BASE_DIR=C:\\github-runner-fleet\\windows-docker LUME_RUNNER_BASE_DIR=${directory}/lume LUME_RUNNER_ENV_FILE=${lumeRunnerEnvPath} +LUME_GUEST_PASSWORD=secret `, "utf8" ); @@ -104,6 +111,49 @@ pool: "utf8" ); + const linuxConfigPath = path.join(directory, "linux-docker-runners.yaml"); + fs.writeFileSync( + linuxConfigPath, + `version: 1 +image: + repository: ghcr.io/example/github-runner-fleet + tag: 0.1.9 +pools: + - key: linux-docker-private + organization: example + runnerGroup: linux-docker-private + repositoryAccess: selected + allowedRepositories: + - example/private-app + labels: [] + size: 1 + architecture: amd64 + runnerRoot: \${LINUX_DOCKER_RUNNER_BASE_DIR}/pools/linux-docker-private +`, + "utf8" + ); + + const windowsConfigPath = path.join(directory, "windows-runners.yaml"); + fs.writeFileSync( + windowsConfigPath, + `version: 1 +plane: windows-docker +image: + repository: ghcr.io/example/github-runner-fleet + tag: 0.1.9-windows +pools: + - key: windows-private + organization: example + runnerGroup: windows-private + repositoryAccess: selected + allowedRepositories: + - example/windows-app + host: windows-host.example.com + sshUser: administrator +`, + "utf8" + ); + const fetchMock = vi.fn(async (url: string) => { if (url.includes("/actions/runner-groups")) { return { @@ -120,6 +170,18 @@ pool: }, { id: 2, + name: "linux-docker-private", + visibility: "selected", + default: false + }, + { + id: 3, + name: "windows-private", + visibility: "selected", + default: false + }, + { + id: 4, name: "macos-private", visibility: "selected", default: false @@ -155,6 +217,8 @@ pool: mode: "full", envPath, configPath: poolsPath, + linuxConfigPath, + windowsConfigPath, lumeConfigPath: lumePath, fetchImpl: fetchMock }); @@ -170,6 +234,14 @@ pool: id: "synology-image", status: "pass" }), + expect.objectContaining({ + id: "linux-docker-runner-groups", + status: "pass" + }), + expect.objectContaining({ + id: "windows-docker-runner-groups", + status: "pass" + }), expect.objectContaining({ id: "lume-runner-group", status: "pass" @@ -468,6 +540,7 @@ pools: `GITHUB_PAT=secret LUME_RUNNER_BASE_DIR=${directory}/lume LUME_RUNNER_ENV_FILE=${lumeRunnerEnvPath} +LUME_GUEST_PASSWORD=secret `, "utf8" ); @@ -568,6 +641,7 @@ pool: `GITHUB_PAT=secret LUME_RUNNER_BASE_DIR=${lumeBaseDir} LUME_RUNNER_ENV_FILE=${lumeRunnerEnvPath} +LUME_GUEST_PASSWORD=secret `, "utf8" ); @@ -637,6 +711,7 @@ pool: envPath, `LUME_RUNNER_BASE_DIR=${directory}/lume LUME_RUNNER_ENV_FILE=${lumeRunnerEnvPath} +LUME_GUEST_PASSWORD=secret `, "utf8" ); @@ -699,6 +774,7 @@ pool: envPath, `GITHUB_PAT=secret LUME_RUNNER_BASE_DIR=${directory}/lume +LUME_GUEST_PASSWORD=secret `, "utf8" ); diff --git a/test/linux-docker-config.test.ts b/test/linux-docker-config.test.ts index d2fe496..0f20866 100644 --- a/test/linux-docker-config.test.ts +++ b/test/linux-docker-config.test.ts @@ -28,7 +28,9 @@ pools: - key: linux-docker-private organization: example runnerGroup: linux-docker-private - repositoryAccess: all + repositoryAccess: selected + allowedRepositories: + - example/private-app labels: - x64 size: 1 @@ -80,9 +82,45 @@ pools: /outside organization example/ ); }); + + test("rejects repositoryAccess all unless break-glass is explicit", () => { + const directory = createTempDir(); + const configPath = path.join(directory, "linux-docker-runners.yaml"); + + fs.writeFileSync( + configPath, + `version: 1 +image: + repository: ghcr.io/example/github-runner-fleet + tag: 0.1.9 +pools: + - key: linux-docker-private + organization: example + runnerGroup: linux-docker-private + repositoryAccess: all + labels: [] + size: 1 + architecture: amd64 + runnerRoot: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private +`, + "utf8" + ); + + expect(() => loadLinuxDockerConfig(configPath, deploymentEnv())).toThrow( + /LINUX_DOCKER_ALLOW_ALL_REPOSITORIES=true/ + ); + expect( + loadLinuxDockerConfig( + configPath, + deploymentEnv({ + LINUX_DOCKER_ALLOW_ALL_REPOSITORIES: "true" + }) + ).pools[0].repositoryAccess + ).toBe("all"); + }); }); -function deploymentEnv(): DeploymentEnv { +function deploymentEnv(raw: Record = {}): DeploymentEnv { return { githubApiUrl: "https://api.github.com", synologyRunnerBaseDir: "/volume1/docker/github-runner-fleet", @@ -117,7 +155,8 @@ function deploymentEnv(): DeploymentEnv { composeProjectName: "github-runner-fleet", runnerVersion: "2.333.0", raw: { - LINUX_DOCKER_RUNNER_BASE_DIR: "/srv/github-runner-fleet/linux-docker" + LINUX_DOCKER_RUNNER_BASE_DIR: "/srv/github-runner-fleet/linux-docker", + ...raw } }; } diff --git a/test/lume-config.test.ts b/test/lume-config.test.ts index 479c62d..2d7b09b 100644 --- a/test/lume-config.test.ts +++ b/test/lume-config.test.ts @@ -116,9 +116,30 @@ pool: "export RUNNER_LABELS='self-hosted,macos,arm64,private'" ); }); + + test("requires the guest password from config or LUME_GUEST_PASSWORD", () => { + const directory = createTempDir(); + const configPath = path.join(directory, "lume-runners.yaml"); + + fs.writeFileSync( + configPath, + `version: 1 +pool: + key: macos-private + size: 1 + vmBaseName: macos-runner-base + vmSlotPrefix: macos-runner-slot +`, + "utf8" + ); + + expect(() => + loadLumeConfig(configPath, deploymentEnv({ LUME_GUEST_PASSWORD: "" })) + ).toThrow(/LUME_GUEST_PASSWORD/); + }); }); -function deploymentEnv(): DeploymentEnv { +function deploymentEnv(raw: Record = {}): DeploymentEnv { return { githubApiUrl: "https://api.github.com", githubPat: undefined, @@ -165,8 +186,10 @@ function deploymentEnv(): DeploymentEnv { "/Users/tester/Library/Application Support/github-runner-fleet/lume/runner.env", LUME_RUNNER_IPSW_PATH: "/Users/tester/Library/Application Support/github-runner-fleet/lume/cache/latest.ipsw", + LUME_GUEST_PASSWORD: "secret", COMPOSE_PROJECT_NAME: "github-runner-fleet", - RUNNER_VERSION: "2.333.0" + RUNNER_VERSION: "2.333.0", + ...raw } }; } diff --git a/test/smoke-harness.test.ts b/test/smoke-harness.test.ts index f6d37a9..7cfe206 100644 --- a/test/smoke-harness.test.ts +++ b/test/smoke-harness.test.ts @@ -18,11 +18,11 @@ function makeTempRoot() { return tempRoot; } -async function waitForReady(logPath: string, port: number) { +async function waitForReady(logPath: string, host: string, port: number) { for (let attempt = 0; attempt < 50; attempt += 1) { if ( fs.existsSync(logPath) && - fs.readFileSync(logPath, "utf8").includes(`listening 0.0.0.0:${port}`) + fs.readFileSync(logPath, "utf8").includes(`listening ${host}:${port}`) ) { return; } @@ -38,9 +38,11 @@ describe("runner registration smoke harness", () => { const tempRoot = makeTempRoot(); const logPath = path.join(tempRoot, "mock-api.log"); const port = 18080 + Number(process.env.VITEST_POOL_ID ?? "0"); + const host = "127.0.0.1"; const server = spawn("node", ["scripts/smoke/mock-api.mjs"], { env: { ...process.env, + MOCK_HOST: host, MOCK_LOG_PATH: logPath, MOCK_PORT: String(port), }, @@ -48,7 +50,7 @@ describe("runner registration smoke harness", () => { }); try { - await waitForReady(logPath, port); + await waitForReady(logPath, host, port); await expect( fetch( diff --git a/test/synology-status.test.ts b/test/synology-status.test.ts index ed0a645..841c39a 100644 --- a/test/synology-status.test.ts +++ b/test/synology-status.test.ts @@ -33,7 +33,7 @@ describe("synology status", () => { JSON.stringify({ ok: true, project: { - name: "synology-github-runner", + name: "github-runner-fleet", status: "running", updated_at: "2026-04-12T08:00:00Z" }, @@ -45,7 +45,7 @@ describe("synology status", () => { end_time: "2026-04-12T08:00:00Z" } }, - remoteLogPath: "/volume1/docker/synology-github-runner/logs/install-project.log" + remoteLogPath: "/volume1/docker/github-runner-fleet/logs/install-project.log" }) ); @@ -133,7 +133,7 @@ function configFixture(): ResolvedConfig { return { version: 1, image: { - repository: "ghcr.io/example/synology-github-runner", + repository: "ghcr.io/example/github-runner-fleet", tag: "0.1.9" }, pools: [ @@ -147,11 +147,11 @@ function configFixture(): ResolvedConfig { labels: ["synology", "shell-only", "private"], size: 1, architecture: "auto", - runnerRoot: "/volume1/docker/synology-github-runner/pools/synology-private", + runnerRoot: "/volume1/docker/github-runner-fleet/pools/synology-private", resources: { memory: "2g" }, - imageRef: "ghcr.io/example/synology-github-runner:0.1.9" + imageRef: "ghcr.io/example/github-runner-fleet:0.1.9" } ] }; @@ -161,7 +161,7 @@ function envFixture(apiRepo: string): DeploymentEnv { return { githubPat: "test-pat", githubApiUrl: "https://api.github.com", - synologyRunnerBaseDir: "/volume1/docker/synology-github-runner", + synologyRunnerBaseDir: "/volume1/docker/github-runner-fleet", synologyHost: "nas.example.com", synologyPort: "5001", synologyUsername: "admin", @@ -170,17 +170,17 @@ function envFixture(apiRepo: string): DeploymentEnv { synologyCertVerify: false, synologyDsmVersion: 7, synologyApiRepo: apiRepo, - synologyProjectDir: "/volume1/docker/synology-github-runner", + synologyProjectDir: "/volume1/docker/github-runner-fleet", synologyProjectComposeFile: "compose.yaml", synologyProjectEnvFile: ".env", synologyInstallPullImages: true, synologyInstallForceRecreate: true, synologyInstallRemoveOrphans: true, lumeRunnerBaseDir: - "/Users/tester/Library/Application Support/synology-github-runner/lume", + "/Users/tester/Library/Application Support/github-runner-fleet/lume", lumeRunnerEnvFile: - "/Users/tester/Library/Application Support/synology-github-runner/lume/runner.env", - composeProjectName: "synology-github-runner", + "/Users/tester/Library/Application Support/github-runner-fleet/lume/runner.env", + composeProjectName: "github-runner-fleet", runnerVersion: "2.333.0", raw: {} }; diff --git a/test/windows-config.test.ts b/test/windows-config.test.ts index 4306ea4..c64c446 100644 --- a/test/windows-config.test.ts +++ b/test/windows-config.test.ts @@ -99,13 +99,17 @@ pools: - key: windows-one organization: example runnerGroup: windows-one - repositoryAccess: all + repositoryAccess: selected + allowedRepositories: + - example/windows-one host: windows-one.example.com sshUser: administrator - key: windows-two organization: example runnerGroup: windows-two - repositoryAccess: all + repositoryAccess: selected + allowedRepositories: + - example/windows-two host: windows-two.example.com sshUser: administrator `, @@ -116,9 +120,44 @@ pools: /must target the same host/ ); }); + + test("rejects repositoryAccess all unless break-glass is explicit", () => { + const directory = createTempDir(); + const configPath = path.join(directory, "windows-runners.yaml"); + + fs.writeFileSync( + configPath, + `version: 1 +plane: windows-docker +image: + repository: ghcr.io/example/github-runner-fleet + tag: 0.1.9-windows +pools: + - key: windows-private + organization: example + runnerGroup: windows-private + repositoryAccess: all + host: windows-host.example.com + sshUser: administrator +`, + "utf8" + ); + + expect(() => loadWindowsDockerConfig(configPath, deploymentEnv())).toThrow( + /WINDOWS_DOCKER_ALLOW_ALL_REPOSITORIES=true/ + ); + expect( + loadWindowsDockerConfig( + configPath, + deploymentEnv({ + WINDOWS_DOCKER_ALLOW_ALL_REPOSITORIES: "true" + }) + ).pools[0].repositoryAccess + ).toBe("all"); + }); }); -function deploymentEnv(): DeploymentEnv { +function deploymentEnv(raw: Record = {}): DeploymentEnv { return { githubApiUrl: "https://api.github.com", synologyRunnerBaseDir: "/volume1/docker/github-runner-fleet", @@ -163,7 +202,8 @@ function deploymentEnv(): DeploymentEnv { composeProjectName: "github-runner-fleet", runnerVersion: "2.333.0", raw: { - WINDOWS_DOCKER_RUNNER_BASE_DIR: "C:\\github-runner-fleet\\windows-docker" + WINDOWS_DOCKER_RUNNER_BASE_DIR: "C:\\github-runner-fleet\\windows-docker", + ...raw } }; } diff --git a/test/workflow-cookbook.test.ts b/test/workflow-cookbook.test.ts index 62db48d..dd69b8c 100644 --- a/test/workflow-cookbook.test.ts +++ b/test/workflow-cookbook.test.ts @@ -11,7 +11,9 @@ describe("workflow cookbook docs", () => { expect(cookbook).toContain("## Runner compatibility matrix"); expect(cookbook).toContain("| Node install, lint, test, build | Yes |"); - expect(cookbook).toContain("| Public fork pull requests | No | No | Yes |"); + expect(cookbook).toContain( + "| Public fork pull requests | No | No | No | No | Yes |" + ); expect(cookbook).toContain("## Recipe: trusted Node job on the Synology shell-only pool"); expect(cookbook).toContain("## Recipe: trusted jobs on self-hosted, fork PRs on GitHub-hosted"); @@ -19,7 +21,9 @@ describe("workflow cookbook docs", () => { expect(cookbook).toContain("## Recipe: Terraform validation on the Synology shell-only pool"); expect(cookbook).toContain("## Recipe: Lume macOS contract job"); - expect(cookbook).toContain("OMT-Global/synology-github-runner/actions/setup-shell-safe-node@main"); + expect(cookbook).toContain("OMT-Global/github-runner-fleet/actions/setup-shell-safe-node@main"); + expect(cookbook).toContain("[self-hosted, linux, docker-capable, private]"); + expect(cookbook).toContain("[self-hosted, windows, docker-capable, private]"); expect(cookbook).toContain("actions/setup-python@v6"); expect(cookbook).toContain("github.event.pull_request.head.repo.full_name != github.repository"); expect(cookbook).toContain("runs-on: ubuntu-latest"); diff --git a/test/workflow.test.ts b/test/workflow.test.ts index e2cf92f..ec4168f 100644 --- a/test/workflow.test.ts +++ b/test/workflow.test.ts @@ -201,6 +201,52 @@ describe("CI workflow", () => { expect(workflow.jobs.test_public_fork_pr["runs-on"]).toBe("ubuntu-latest"); }); + test("keeps PR fast checks on self-hosted only for same-repo PRs", () => { + const workflow = YAML.parse( + fs.readFileSync(path.resolve(".github/workflows/pr-fast-ci.yml"), "utf8") + ) as { + jobs: Record>; + }; + + const selfHostedJobs = [ + workflow.jobs["fast-checks"], + workflow.jobs["validate-secrets"] + ]; + for (const job of selfHostedJobs) { + expect(job["runs-on"]).toEqual([ + "self-hosted", + "synology", + "shell-only", + "public" + ]); + expect(String(job.if)).toContain( + "github.event.pull_request.head.repo.full_name == github.repository" + ); + } + + expect(workflow.jobs.changes["runs-on"]).toBe("ubuntu-latest"); + expect(workflow.jobs["hosted-fork-fast-checks"]["runs-on"]).toBe( + "ubuntu-latest" + ); + expect(String(workflow.jobs["hosted-fork-fast-checks"].if)).toContain( + "github.event.pull_request.head.repo.full_name != github.repository" + ); + expect(workflow.jobs["hosted-fork-validate-secrets"]["runs-on"]).toBe( + "ubuntu-latest" + ); + expect(String(workflow.jobs["hosted-fork-validate-secrets"].if)).toContain( + "github.event.pull_request.head.repo.full_name != github.repository" + ); + expect(workflow.jobs["ci-gate"].needs).toEqual( + expect.arrayContaining([ + "fast-checks", + "validate-secrets", + "hosted-fork-fast-checks", + "hosted-fork-validate-secrets" + ]) + ); + }); + test("renders the Linux Docker contract on hosted Linux before operators provision the pool", () => { const workflow = YAML.parse( fs.readFileSync(path.resolve(".github/workflows/ci.yml"), "utf8") From 83e390c8bbde3c1db234351be06b0f63db6a6599 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Thu, 30 Apr 2026 15:35:51 -0500 Subject: [PATCH 3/4] Cover Windows Docker doctor skip path --- test/doctor.test.ts | 64 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/test/doctor.test.ts b/test/doctor.test.ts index 28a1dd8..abc7a97 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -621,6 +621,70 @@ pools: ); }); + test("fails Windows Docker doctor when required env is missing and skips GitHub checks without a PAT", async () => { + const directory = createTempDir(); + const envPath = path.join(directory, ".env"); + fs.writeFileSync( + envPath, + `WINDOWS_DOCKER_RUNNER_BASE_DIR=C:\\github-runner-fleet\\windows-docker +`, + "utf8" + ); + + const windowsConfigPath = path.join(directory, "windows-runners.yaml"); + fs.writeFileSync( + windowsConfigPath, + `version: 1 +plane: windows-docker +image: + repository: ghcr.io/example/github-runner-fleet + tag: 0.1.9-windows +pools: + - key: windows-private + organization: example + runnerGroup: windows-private + repositoryAccess: selected + allowedRepositories: + - example/windows-app + host: windows-host.example.com + sshUser: administrator +`, + "utf8" + ); + + const report = await withEnv( + { + GITHUB_PAT: undefined, + GITHUB_TOKEN: undefined, + GH_TOKEN: undefined + }, + () => + runDoctor({ + mode: "windows-docker", + envPath, + windowsConfigPath + }) + ); + + expect(report.ok).toBe(false); + expect(report.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "windows-docker-env", + status: "fail" + }), + expect.objectContaining({ + id: "windows-docker-config", + status: "pass" + }), + expect.objectContaining({ + id: "windows-docker-runner-groups", + status: "skip" + }) + ]) + ); + }); + test("warns in Lume mode when the runner env file is missing", async () => { const directory = createTempDir(); const envPath = path.join(directory, ".env"); From 77d7e839245a5495ced62281daf1655fc6d558b4 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sat, 2 May 2026 00:13:03 -0500 Subject: [PATCH 4/4] Keep PR gate on hosted runners --- .github/workflows/pr-fast-ci.yml | 2 +- test/workflow.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-fast-ci.yml b/.github/workflows/pr-fast-ci.yml index 23d3f8d..e3c3968 100644 --- a/.github/workflows/pr-fast-ci.yml +++ b/.github/workflows/pr-fast-ci.yml @@ -130,7 +130,7 @@ jobs: ci-gate: name: CI Gate - runs-on: ['self-hosted', 'synology', 'shell-only', 'public'] + runs-on: ubuntu-latest if: always() needs: - changes diff --git a/test/workflow.test.ts b/test/workflow.test.ts index c83d76e..ebfee59 100644 --- a/test/workflow.test.ts +++ b/test/workflow.test.ts @@ -245,6 +245,7 @@ describe("CI workflow", () => { "hosted-fork-validate-secrets" ]) ); + expect(workflow.jobs["ci-gate"]["runs-on"]).toBe("ubuntu-latest"); }); test("renders the Linux Docker contract on hosted Linux before operators provision the pool", () => {