From edeba70b3fff0b193dc5c8d7c8dd715e29ac8695 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 25 Apr 2026 10:22:06 -0500 Subject: [PATCH] Add Linux Docker status surface --- docs/linux-docker-pool.md | 8 +- docs/private-repo-parity.md | 4 +- package.json | 2 + src/cli.ts | 205 +++++++++++++++++++++-- src/lib/doctor.ts | 163 +++++++++++++++++- src/lib/linux-docker-status.ts | 274 +++++++++++++++++++++++++++++++ test/doctor.test.ts | 104 ++++++++++++ test/linux-docker-status.test.ts | 229 ++++++++++++++++++++++++++ 8 files changed, 967 insertions(+), 22 deletions(-) create mode 100644 src/lib/linux-docker-status.ts create mode 100644 test/linux-docker-status.test.ts diff --git a/docs/linux-docker-pool.md b/docs/linux-docker-pool.md index 8a36dcf..ca649cd 100644 --- a/docs/linux-docker-pool.md +++ b/docs/linux-docker-pool.md @@ -25,16 +25,20 @@ This plane exists for private-repo workflows that need real Linux Docker semanti ## Operator Commands ```bash +pnpm doctor -- linux-docker --env .env --linux-docker-config config/linux-docker-runners.yaml 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 +pnpm linux-docker-status -- --config config/linux-docker-runners.yaml --env .env --result .tmp/linux-docker-status.json pnpm render-linux-docker-compose -- --config config/linux-docker-runners.yaml --env .env --output docker-compose.linux-docker.yml pnpm render-linux-docker-project-manifest -- --config config/linux-docker-runners.yaml --env .env -pnpm install-linux-docker-project -- --config config/linux-docker-runners.yaml --env .env -pnpm teardown-linux-docker-project -- --config config/linux-docker-runners.yaml --env .env +pnpm install-linux-docker-project -- --config config/linux-docker-runners.yaml --env .env --status-output .tmp/linux-docker-status.json +pnpm teardown-linux-docker-project -- --config config/linux-docker-runners.yaml --env .env --status-output .tmp/linux-docker-status.json ``` `install-linux-docker-project` and `teardown-linux-docker-project` use `ssh` and `scp` to stage the compose project on `LINUX_DOCKER_HOST`, then run a generated deployment script on that host. Use SSH keys or agent forwarding; do not bake credentials into the runner image. +Use `pnpm doctor -- linux-docker ...` for preflight validation and `pnpm linux-docker-status ...` to inspect the latest saved install or teardown result after a remote run. + ## Example Workflow Placements Container jobs: diff --git a/docs/private-repo-parity.md b/docs/private-repo-parity.md index d0473d7..c680994 100644 --- a/docs/private-repo-parity.md +++ b/docs/private-repo-parity.md @@ -5,7 +5,7 @@ This guide answers two operator questions: 1. Where should a given GitHub Actions job run? 2. What still needs GitHub-hosted runners after the self-hosted planes are in place? -Use it alongside `pnpm doctor`, the main [README](../README.md), and the open backlog for gaps that are still intentionally unresolved. +Use it alongside `pnpm doctor`, `pnpm synology-status`, `pnpm linux-docker-status`, the main [README](../README.md), and the open backlog for gaps that are still intentionally unresolved. ## Current Runner Classes @@ -23,7 +23,7 @@ These are the main places where GitHub-hosted runners still have broader surface - Broad Linux image/tooling coverage: even with the Linux Docker plane, GitHub-hosted runners still win when you need the full hosted image catalog without curating your own host baseline. - Self-hosted operator ergonomics: - the repo is adding better doctor/status surfaces, but GitHub-hosted still wins on out-of-the-box visibility. + the repo now has first-class doctor/status entrypoints for Synology and Linux Docker, but GitHub-hosted still wins on out-of-the-box visibility. - Public fork trust boundaries: keep untrusted PRs on GitHub-hosted runners unless a workflow has been designed very deliberately for that exposure model. diff --git a/package.json b/package.json index b459f01..288fa83 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "drain-pool": "tsx src/cli.ts drain-pool", "install-synology-project": "tsx src/cli.ts install-synology-project", "install-linux-docker-project": "tsx src/cli.ts install-linux-docker-project", + "linux-docker-status": "tsx src/cli.ts linux-docker-status", "install-lume-project": "tsx src/cli.ts install-lume-project", "install-windows-project": "tsx src/cli.ts install-windows-project", "lint": "tsc --noEmit -p tsconfig.json", @@ -36,6 +37,7 @@ "runner-release-manifest": "tsx src/cli.ts runner-release-manifest", "scale": "tsx src/cli.ts scale", "smoke-test": "bash scripts/smoke-test.sh", + "synology-status": "tsx src/cli.ts synology-status", "test": "vitest run", "test:coverage": "vitest run --coverage", "mutation-test": "stryker run", diff --git a/src/cli.ts b/src/cli.ts index d01710c..8327e5f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,11 @@ import { summarizeLinuxDockerInstallPlan } from "./lib/linux-docker-install.js"; import { renderLinuxDockerCompose } from "./lib/linux-docker-compose.js"; +import { + buildLinuxDockerStatusReport, + formatLinuxDockerStatusText, + saveLinuxDockerResult +} from "./lib/linux-docker-status.js"; import { loadWindowsDockerConfig } from "./lib/windows-config.js"; import { buildWindowsDockerInstallPlan, @@ -77,6 +82,11 @@ import { buildSynologyInstallPlan, summarizeSynologyInstallPlan } from "./lib/synology-install.js"; +import { + buildSynologyStatusReport, + formatSynologyStatusText, + saveSynologyResult +} from "./lib/synology-status.js"; import { log, type LogFields, type LogLevel } from "./lib/logger.js"; export async function main( @@ -91,6 +101,12 @@ export async function main( case "doctor": await doctorCommand(args); break; + case "synology-status": + await synologyStatusCommand(args); + break; + case "linux-docker-status": + await linuxDockerStatusCommand(args); + break; case "drift-detect": await driftDetectCommand(args); break; @@ -234,6 +250,11 @@ async function doctorCommand(args: string[]): Promise { mode, envPath: getOption(args, "--env", ".env"), configPath: getOption(args, "--config", "config/pools.yaml"), + linuxDockerConfigPath: getOption( + args, + "--linux-docker-config", + "config/linux-docker-runners.yaml" + ), lumeConfigPath: getOption(args, "--lume-config", "config/lume-runners.yaml") }); @@ -251,6 +272,65 @@ async function doctorCommand(args: string[]): Promise { } } +async function synologyStatusCommand(args: string[]): Promise { + const env = loadDeploymentEnv({ + envPath: getOption(args, "--env", ".env"), + requirePat: false + }); + const configPath = getOption(args, "--config", "config/pools.yaml"); + const format = getOption(args, "--format", "text"); + const config = loadConfig(configPath!, env); + emitWarnings(config); + const compose = renderCompose(config, env); + const report = buildSynologyStatusReport({ + config, + env, + composeContent: compose, + savedResultPath: getOption(args, "--result") + }); + + if (format === "json") { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + } else if (format === "text") { + process.stdout.write(formatSynologyStatusText(report)); + } else { + throw new Error(`unknown synology-status format: ${format}`); + } + + if (!report.ok) { + process.exitCode = 1; + } +} + +async function linuxDockerStatusCommand(args: string[]): Promise { + const env = loadDeploymentEnv({ + envPath: getOption(args, "--env", ".env"), + requirePat: false + }); + const configPath = getOption(args, "--config", "config/linux-docker-runners.yaml"); + const format = getOption(args, "--format", "text"); + const config = loadLinuxDockerConfig(configPath!, env); + const compose = renderLinuxDockerCompose(config, env); + const report = buildLinuxDockerStatusReport({ + config, + env, + composeContent: compose, + savedResultPath: getOption(args, "--result") + }); + + if (format === "json") { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + } else if (format === "text") { + process.stdout.write(formatLinuxDockerStatusText(report)); + } else { + throw new Error(`unknown linux-docker-status format: ${format}`); + } + + if (!report.ok) { + process.exitCode = 1; + } +} + async function driftDetectCommand(args: string[]): Promise { const env = loadDeploymentEnv({ envPath: getOption(args, "--env", ".env"), @@ -987,6 +1067,7 @@ async function renderWindowsDockerProjectManifest(args: string[]): Promise async function installSynologyProject(args: string[]): Promise { const dryRun = args.includes("--dry-run"); + const statusOutput = getOption(args, "--status-output"); const env = loadDeploymentEnv({ envPath: getOption(args, "--env", ".env"), requirePat: !dryRun @@ -1023,12 +1104,16 @@ async function installSynologyProject(args: string[]): Promise { pool: "all", dryRun: false }, () => { - runSynologyInstallPlan(plan, python); + const output = runSynologyInstallPlan(plan, python); + if (statusOutput) { + saveSynologyResult(statusOutput, "up", output); + } }); } async function installLinuxDockerProject(args: string[]): Promise { const dryRun = args.includes("--dry-run"); + const statusOutput = getOption(args, "--status-output"); const env = loadDeploymentEnv({ envPath: getOption(args, "--env", ".env"), requirePat: !dryRun @@ -1063,12 +1148,45 @@ async function installLinuxDockerProject(args: string[]): Promise { pool: "all", dryRun: false }, () => { - runLinuxDockerInstall(plan); + try { + const composePsOutput = runLinuxDockerInstall(plan); + if (statusOutput) { + saveLinuxDockerResult(statusOutput, { + ok: true, + action: "up", + remoteLogPath: buildLinuxDockerRemoteLogPath(plan), + composePsOutput, + connection: plan.connection, + project: { + name: plan.project.name, + directory: plan.project.directory + }, + options: { ...plan.options } + }); + } + } catch (error) { + if (statusOutput) { + saveLinuxDockerResult(statusOutput, { + ok: false, + action: "up", + remoteLogPath: buildLinuxDockerRemoteLogPath(plan), + error: formatError(error), + connection: plan.connection, + project: { + name: plan.project.name, + directory: plan.project.directory + }, + options: { ...plan.options } + }); + } + throw error; + } }); } async function teardownSynologyProject(args: string[]): Promise { const dryRun = args.includes("--dry-run"); + const statusOutput = getOption(args, "--status-output"); const env = loadDeploymentEnv({ envPath: getOption(args, "--env", ".env"), requirePat: !dryRun @@ -1115,14 +1233,17 @@ async function teardownSynologyProject(args: string[]): Promise { ]); const python = getOption(args, "--python", "python3")!; - runSynologyInstallPlan(plan, python); + const output = runSynologyInstallPlan(plan, python); + if (statusOutput) { + saveSynologyResult(statusOutput, "down", output); + } }); } function runSynologyInstallPlan( plan: ReturnType, python: string -): void { +): string { const scriptPath = path.resolve("scripts/install-synology-project.py"); const result = spawnSync(python, [scriptPath], { input: JSON.stringify(plan), @@ -1137,10 +1258,12 @@ function runSynologyInstallPlan( } process.stdout.write(result.stdout); + return result.stdout; } async function teardownLinuxDockerProject(args: string[]): Promise { const dryRun = args.includes("--dry-run"); + const statusOutput = getOption(args, "--status-output"); const env = loadDeploymentEnv({ envPath: getOption(args, "--env", ".env"), requirePat: !dryRun @@ -1185,7 +1308,39 @@ async function teardownLinuxDockerProject(args: string[]): Promise { })) ]); - runLinuxDockerInstall(plan); + try { + const composePsOutput = runLinuxDockerInstall(plan); + if (statusOutput) { + saveLinuxDockerResult(statusOutput, { + ok: true, + action: "down", + remoteLogPath: buildLinuxDockerRemoteLogPath(plan), + composePsOutput, + connection: plan.connection, + project: { + name: plan.project.name, + directory: plan.project.directory + }, + options: { ...plan.options } + }); + } + } catch (error) { + if (statusOutput) { + saveLinuxDockerResult(statusOutput, { + ok: false, + action: "down", + remoteLogPath: buildLinuxDockerRemoteLogPath(plan), + error: formatError(error), + connection: plan.connection, + project: { + name: plan.project.name, + directory: plan.project.directory + }, + options: { ...plan.options } + }); + } + throw error; + } }); } @@ -2306,6 +2461,7 @@ function getDoctorMode(args: string[]): DoctorMode { const optionFlags = new Set([ "--env", "--config", + "--linux-docker-config", "--lume-config", "--format" ]); @@ -2321,7 +2477,7 @@ function getDoctorMode(args: string[]): DoctorMode { continue; } - if (arg === "full" || arg === "synology" || arg === "lume") { + if (arg === "full" || arg === "synology" || arg === "linux-docker" || arg === "lume") { return arg; } @@ -2400,19 +2556,20 @@ function parseDurationSeconds(value: string, optionName: string): number { function runLinuxDockerInstall( plan: ReturnType -): void { +): string { const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), "linux-docker-install-")); const composePath = path.join(stagingDir, plan.project.composeFileName); const envPath = path.join(stagingDir, plan.project.envFileName); const scriptPath = path.join(stagingDir, plan.project.deploymentScriptName); const remote = `${plan.connection.username}@${plan.connection.host}`; + let output = ""; try { fs.writeFileSync(composePath, `${plan.composeContent}\n`, "utf8"); fs.writeFileSync(envPath, plan.envFileContent, "utf8"); fs.writeFileSync(scriptPath, plan.deploymentScript, "utf8"); - runCommand( + output += runCommand( "ssh", [ "-p", @@ -2428,7 +2585,7 @@ function runLinuxDockerInstall( "failed to prepare remote Linux Docker host" ); - runCommand( + output += runCommand( "scp", [ "-P", @@ -2441,7 +2598,7 @@ function runLinuxDockerInstall( "failed to upload Linux Docker project files" ); - runCommand( + output += runCommand( "ssh", [ "-p", @@ -2460,6 +2617,8 @@ function runLinuxDockerInstall( } finally { fs.rmSync(stagingDir, { recursive: true, force: true }); } + + return output.trim(); } function runWindowsDockerInstall( @@ -2634,7 +2793,7 @@ function runCommand( command: string, args: string[], errorPrefix: string -): void { +): string { const result = spawnSync(command, args, { encoding: "utf8", env: process.env @@ -2651,6 +2810,8 @@ function runCommand( if (result.stdout) { process.stdout.write(result.stdout); } + + return result.stdout ?? ""; } function shellQuote(value: string): string { @@ -2661,6 +2822,16 @@ function shellEscapeRemotePath(value: string): string { return value.replace(/(["\\$`])/g, "\\$1"); } +function buildLinuxDockerRemoteLogPath( + plan: ReturnType +): string { + return path.posix.join(plan.project.directory, "logs", plan.project.logFileName); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function windowsRemotePath(value: string): string { return value.replace(/\\/g, "/"); } @@ -2671,7 +2842,9 @@ 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|lume] [--env .env] [--config config/pools.yaml] [--linux-docker-config config/linux-docker-runners.yaml] [--lume-config config/lume-runners.yaml] [--format text|json] + pnpm synology-status [--config config/pools.yaml] [--env .env] [--result .tmp/synology-status.json] [--format text|json] + pnpm linux-docker-status [--config config/linux-docker-runners.yaml] [--env .env] [--result .tmp/linux-docker-status.json] [--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] @@ -2691,13 +2864,13 @@ function printUsage(): void { pnpm render-windows-compose [--config config/windows-runners.yaml] [--env .env] [--output docker-compose.windows.yml] pnpm render-windows-project-manifest [--config config/windows-runners.yaml] [--env .env] pnpm render-compose [--config config/pools.yaml] [--env .env] [--output docker-compose.generated.yml] - pnpm install-linux-docker-project [--config config/linux-docker-runners.yaml] [--env .env] [--dry-run] - pnpm teardown-linux-docker-project [--config config/linux-docker-runners.yaml] [--env .env] [--dry-run] [--drain] [--drain-timeout 15m] + pnpm install-linux-docker-project [--config config/linux-docker-runners.yaml] [--env .env] [--status-output .tmp/linux-docker-status.json] [--dry-run] + pnpm teardown-linux-docker-project [--config config/linux-docker-runners.yaml] [--env .env] [--status-output .tmp/linux-docker-status.json] [--dry-run] [--drain] [--drain-timeout 15m] pnpm install-windows-project [--config config/windows-runners.yaml] [--env .env] [--dry-run] pnpm teardown-windows-project [--config config/windows-runners.yaml] [--env .env] [--dry-run] [--drain] [--drain-timeout 15m] pnpm render-synology-project-manifest [--config config/pools.yaml] [--env .env] - pnpm install-synology-project [--config config/pools.yaml] [--env .env] [--dry-run] [--python python3] - pnpm teardown-synology-project [--config config/pools.yaml] [--env .env] [--dry-run] [--drain] [--drain-timeout 15m] [--python python3] + pnpm install-synology-project [--config config/pools.yaml] [--env .env] [--status-output .tmp/synology-status.json] [--dry-run] [--python python3] + pnpm teardown-synology-project [--config config/pools.yaml] [--env .env] [--status-output .tmp/synology-status.json] [--dry-run] [--drain] [--drain-timeout 15m] [--python python3] pnpm check-runner-version [--current 2.333.0] [--env .env] pnpm runner-release-manifest [--current 2.333.0] [--env .env] pnpm validate-lume-config [--config config/lume-runners.yaml] [--env .env] diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index e6dad7d..707f1bf 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -7,6 +7,7 @@ import { verifyContainerImageTag, verifyRunnerGroups } from "./github.js"; +import { loadLinuxDockerConfig } from "./linux-docker-config.js"; import { log, type LogLevel } from "./logger.js"; import { loadLumeConfig } from "./lume-config.js"; import { @@ -20,12 +21,12 @@ import { type MetricSample } from "./metrics.js"; -export type DoctorMode = "full" | "synology" | "lume"; +export type DoctorMode = "full" | "synology" | "linux-docker" | "lume"; export type DoctorCheckStatus = "pass" | "warn" | "fail" | "skip"; export interface DoctorCheck { id: string; - target: "synology" | "lume"; + target: "synology" | "linux-docker" | "lume"; status: DoctorCheckStatus; summary: string; detail?: string; @@ -42,6 +43,7 @@ export interface RunDoctorOptions { mode?: DoctorMode; envPath?: string; configPath?: string; + linuxDockerConfigPath?: string; lumeConfigPath?: string; fetchImpl?: FetchLike; } @@ -52,6 +54,8 @@ export async function runDoctor( const mode = options.mode ?? "full"; const envPath = options.envPath ?? ".env"; const configPath = options.configPath ?? "config/pools.yaml"; + const linuxDockerConfigPath = + options.linuxDockerConfigPath ?? "config/linux-docker-runners.yaml"; const lumeConfigPath = options.lumeConfigPath ?? "config/lume-runners.yaml"; const fetchImpl = options.fetchImpl; const env = loadDeploymentEnv({ @@ -69,6 +73,15 @@ export async function runDoctor( checks.push(...synologyChecks); } + if (mode === "full" || mode === "linux-docker") { + const linuxDockerChecks = await runLinuxDockerDoctor({ + env, + configPath: linuxDockerConfigPath, + fetchImpl + }); + checks.push(...linuxDockerChecks); + } + if (mode === "full" || mode === "lume") { const lumeChecks = await runLumeDoctor({ env, @@ -256,6 +269,142 @@ async function runSynologyDoctor(input: { return checks; } +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; + } + + checks.push({ + id: "linux-docker-host-root", + target: "linux-docker", + status: "pass", + summary: `Linux Docker project root resolves to ${input.env.linuxDockerProjectDir}` + }); + + 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" + }); + checks.push({ + id: "linux-docker-image", + target: "linux-docker", + status: "skip", + summary: "skipped Linux Docker image 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) + }); + } + + const imageRef = `${config.image.repository}:${config.image.tag}`; + try { + const image = await verifyContainerImageTag( + input.env.githubApiUrl, + input.env.githubPat, + imageRef, + input.fetchImpl + ); + checks.push({ + id: "linux-docker-image", + target: "linux-docker", + status: "pass", + summary: `verified ${image.imageRef} in GitHub Packages` + }); + } catch (error) { + checks.push({ + id: "linux-docker-image", + target: "linux-docker", + status: "fail", + summary: `failed image verification for ${imageRef}`, + detail: formatError(error) + }); + } + + return checks; +} + function buildAuditLogCheck(env: Record): DoctorCheck { const filePath = auditLogFileFromEnv(env); let sizeBytes = 0; @@ -491,6 +640,16 @@ function poolSlotMetricsForCheck(check: DoctorCheck): MetricSample[] { ); } + if (check.target === "linux-docker" && isSynologyConfigData(check.data)) { + return check.data.pools.map((pool) => + poolSlotCount({ + plane: "linux-docker", + pool: pool.key, + count: pool.size + }) + ); + } + if (check.target === "lume" && isLumeConfigData(check.data)) { return [ poolSlotCount({ diff --git a/src/lib/linux-docker-status.ts b/src/lib/linux-docker-status.ts new file mode 100644 index 0000000..5b47659 --- /dev/null +++ b/src/lib/linux-docker-status.ts @@ -0,0 +1,274 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { DeploymentEnv } from "./env.js"; +import type { ResolvedLinuxDockerConfig } from "./linux-docker-config.js"; +import { + buildLinuxDockerInstallPlan, + summarizeLinuxDockerInstallPlan, + type LinuxDockerInstallPlan, + type LinuxDockerInstallSummary +} from "./linux-docker-install.js"; + +export interface LinuxDockerSavedResult { + ok: boolean; + action?: "up" | "down"; + recordedAt?: string; + remoteLogPath?: string; + composePsOutput?: string; + error?: string; + connection?: { + host?: string; + port?: string; + username?: string; + }; + project?: { + name?: string; + directory?: string; + }; + options?: Record; +} + +export interface LinuxDockerStatusCheck { + key: string; + ok: boolean; + summary: string; +} + +export interface LinuxDockerTroubleshootingHint { + symptom: string; + nextStep: string; +} + +export interface LinuxDockerStatusReport { + ok: boolean; + summary: LinuxDockerInstallSummary; + checks: LinuxDockerStatusCheck[]; + remoteLogPath: string; + savedResultPath?: string; + savedResult?: LinuxDockerSavedResult; + troubleshooting: LinuxDockerTroubleshootingHint[]; +} + +export function buildLinuxDockerStatusReport(options: { + config: ResolvedLinuxDockerConfig; + env: DeploymentEnv; + composeContent: string; + savedResultPath?: string; +}): LinuxDockerStatusReport { + const plan = buildLinuxDockerInstallPlan( + options.config, + options.env, + options.composeContent, + { + allowIncomplete: true, + action: "up" + } + ); + const summary = summarizeLinuxDockerInstallPlan(plan); + const remoteLogPath = path.posix.join( + plan.project.directory, + "logs", + plan.project.logFileName + ); + const resolvedSavedResultPath = options.savedResultPath + ? path.resolve(options.savedResultPath) + : undefined; + const checks = buildChecks(plan, resolvedSavedResultPath); + const savedResult = resolvedSavedResultPath + ? loadSavedResult(resolvedSavedResultPath) + : undefined; + + if (savedResult && resolvedSavedResultPath) { + checks.push(...buildSavedResultChecks(savedResult, resolvedSavedResultPath)); + } else if (resolvedSavedResultPath) { + checks.push({ + key: "saved_result", + ok: false, + summary: `no saved Linux Docker result found at ${resolvedSavedResultPath}` + }); + } + + return { + ok: checks.every((check) => check.ok), + summary, + checks, + remoteLogPath, + savedResultPath: resolvedSavedResultPath, + savedResult, + troubleshooting: buildTroubleshooting(plan, savedResult, remoteLogPath) + }; +} + +export function formatLinuxDockerStatusText( + report: LinuxDockerStatusReport +): string { + const lines = [ + `linux-docker-status ok=${report.ok ? "true" : "false"}`, + `project=${report.summary.project.name} host=${report.summary.connection.host || ""}`, + `remote_log=${report.remoteLogPath}` + ]; + + for (const check of report.checks) { + lines.push(`- [${check.ok ? "ok" : "fail"}] ${check.key}: ${check.summary}`); + } + + if (report.savedResult?.action) { + lines.push(`recent_action=${report.savedResult.action}`); + } + + if (report.savedResult?.composePsOutput) { + lines.push("recent_compose_ps:"); + for (const line of report.savedResult.composePsOutput.split("\n")) { + if (line.trim()) { + lines.push(` ${line}`); + } + } + } + + if (report.savedResult?.error) { + lines.push(`recent_error=${report.savedResult.error}`); + } + + lines.push("troubleshooting:"); + for (const hint of report.troubleshooting) { + lines.push(`- ${hint.symptom}: ${hint.nextStep}`); + } + + return `${lines.join("\n")}\n`; +} + +export function saveLinuxDockerResult( + outputPath: string, + payload: LinuxDockerSavedResult +): LinuxDockerSavedResult { + const record: LinuxDockerSavedResult = { + ...payload, + recordedAt: payload.recordedAt ?? new Date().toISOString() + }; + fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true }); + fs.writeFileSync( + path.resolve(outputPath), + `${JSON.stringify(record, null, 2)}\n`, + "utf8" + ); + return record; +} + +function loadSavedResult( + savedResultPath: string +): LinuxDockerSavedResult | undefined { + const resolved = path.resolve(savedResultPath); + if (!fs.existsSync(resolved)) { + return undefined; + } + return JSON.parse(fs.readFileSync(resolved, "utf8")) as LinuxDockerSavedResult; +} + +function buildChecks( + plan: LinuxDockerInstallPlan, + savedResultPath?: string +): LinuxDockerStatusCheck[] { + const connection = plan.connection; + const githubPatConfigured = !plan.envFileContent.includes('GITHUB_PAT=""'); + return [ + { + key: "linux_docker_env", + ok: Boolean(connection.host && connection.username), + summary: + connection.host && connection.username + ? `Linux Docker host ${connection.host}:${connection.port} SSH access is configured` + : "LINUX_DOCKER_HOST or LINUX_DOCKER_USERNAME is missing" + }, + { + key: "github_pat", + ok: githubPatConfigured, + summary: githubPatConfigured + ? "GITHUB_PAT is configured for remote runner registration" + : "GITHUB_PAT is missing from the deployment env" + }, + { + key: "compose_project", + ok: true, + summary: `project ${plan.project.name} will deploy under ${plan.project.directory}` + }, + { + key: "saved_result_path", + ok: savedResultPath ? fs.existsSync(path.resolve(savedResultPath)) : true, + summary: savedResultPath + ? fs.existsSync(path.resolve(savedResultPath)) + ? `saved result found at ${path.resolve(savedResultPath)}` + : `save install output with --status-output or provide --result ${path.resolve(savedResultPath)}` + : "use --result or --status-output to inspect the latest saved install result" + } + ]; +} + +function buildSavedResultChecks( + savedResult: LinuxDockerSavedResult, + savedResultPath: string +): LinuxDockerStatusCheck[] { + const checks: LinuxDockerStatusCheck[] = [ + { + key: "saved_result", + ok: true, + summary: `loaded saved Linux Docker result from ${path.resolve(savedResultPath)}` + } + ]; + + checks.push({ + key: "recent_result", + ok: savedResult.ok, + summary: savedResult.ok + ? `latest Linux Docker ${savedResult.action ?? "up"} action completed successfully` + : `latest Linux Docker ${savedResult.action ?? "up"} action failed` + }); + + if (savedResult.remoteLogPath) { + checks.push({ + key: "recent_log", + ok: true, + summary: `latest remote log path ${savedResult.remoteLogPath}` + }); + } + + return checks; +} + +function buildTroubleshooting( + plan: LinuxDockerInstallPlan, + savedResult: LinuxDockerSavedResult | undefined, + remoteLogPath: string +): LinuxDockerTroubleshootingHint[] { + const hints: LinuxDockerTroubleshootingHint[] = [ + { + symptom: "GitHub auth or registration failures", + nextStep: + "Run `pnpm validate-linux-docker-github -- --config config/linux-docker-runners.yaml --env .env` and confirm GITHUB_PAT plus runner groups are still valid." + }, + { + symptom: "SSH connectivity or remote permission failures", + nextStep: + "Confirm the Linux Docker host is reachable over SSH with the configured user and that the target project directory is writable." + }, + { + symptom: "Docker binary or compose failures on the remote host", + nextStep: `Inspect ${remoteLogPath} and verify Docker plus Compose are installed and available to the remote user.` + }, + { + symptom: "Need a clean teardown or recovery cycle", + nextStep: + "Run `pnpm teardown-linux-docker-project -- --config config/linux-docker-runners.yaml --env .env --status-output .tmp/linux-docker-status.json`, confirm the saved result, then reinstall." + } + ]; + + if (savedResult && !savedResult.ok) { + hints.unshift({ + symptom: "Latest saved install attempt failed", + nextStep: savedResult.error + ? `Start with ${remoteLogPath} and the saved error: ${savedResult.error}` + : `Start with ${remoteLogPath} and the latest saved status result.` + }); + } + + return hints; +} diff --git a/test/doctor.test.ts b/test/doctor.test.ts index 048f50c..4e3a315 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -60,6 +60,10 @@ SYNOLOGY_HOST=nas.example.com SYNOLOGY_USERNAME=admin SYNOLOGY_PASSWORD=secret SYNOLOGY_RUNNER_BASE_DIR=${directory}/synology +LINUX_DOCKER_HOST=docker.example.com +LINUX_DOCKER_USERNAME=runner +LINUX_DOCKER_PROJECT_DIR=${directory}/linux-docker +LINUX_DOCKER_RUNNER_BASE_DIR=${directory}/linux-docker LUME_RUNNER_BASE_DIR=${directory}/lume LUME_RUNNER_ENV_FILE=${lumeRunnerEnvPath} `, @@ -87,6 +91,26 @@ pools: "utf8" ); + const linuxDockerPath = path.join(directory, "linux-docker-runners.yaml"); + fs.writeFileSync( + linuxDockerPath, + `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: \${LINUX_DOCKER_RUNNER_BASE_DIR}/pools/linux-docker-private +`, + "utf8" + ); + const lumePath = path.join(directory, "lume-runners.yaml"); fs.writeFileSync( lumePath, @@ -120,6 +144,12 @@ pool: }, { id: 2, + name: "linux-docker-private", + visibility: "selected", + default: false + }, + { + id: 3, name: "macos-private", visibility: "selected", default: false @@ -155,6 +185,7 @@ pool: mode: "full", envPath, configPath: poolsPath, + linuxDockerConfigPath: linuxDockerPath, lumeConfigPath: lumePath, fetchImpl: fetchMock }); @@ -170,6 +201,14 @@ pool: id: "synology-image", status: "pass" }), + expect.objectContaining({ + id: "linux-docker-runner-groups", + status: "pass" + }), + expect.objectContaining({ + id: "linux-docker-image", + status: "pass" + }), expect.objectContaining({ id: "lume-runner-group", status: "pass" @@ -229,6 +268,7 @@ pool: expect(rendered).toContain("doctor mode: full"); expect(rendered).toContain("PASS audit-log: audit log path"); expect(rendered).toContain("PASS synology-image"); + expect(rendered).toContain("PASS linux-docker-image"); expect(rendered).toContain("overall: PASS"); }); @@ -458,6 +498,70 @@ pools: ); }); + test("fails Linux 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, + `LINUX_DOCKER_PROJECT_DIR=${directory}/linux-docker +LINUX_DOCKER_RUNNER_BASE_DIR=${directory}/linux-docker +`, + "utf8" + ); + + const linuxDockerPath = path.join(directory, "linux-docker-runners.yaml"); + fs.writeFileSync( + linuxDockerPath, + `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: \${LINUX_DOCKER_RUNNER_BASE_DIR}/pools/linux-docker-private +`, + "utf8" + ); + + const report = await withEnv( + { + GITHUB_PAT: undefined, + GITHUB_TOKEN: undefined, + GH_TOKEN: undefined + }, + () => + runDoctor({ + mode: "linux-docker", + envPath, + linuxDockerConfigPath: linuxDockerPath + }) + ); + + expect(report.ok).toBe(false); + expect(report.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "linux-docker-env", + status: "fail" + }), + expect.objectContaining({ + id: "linux-docker-runner-groups", + status: "skip" + }), + expect.objectContaining({ + id: "linux-docker-image", + status: "skip" + }) + ]) + ); + }); + test("warns in Lume mode when the runner env file is missing", async () => { const directory = createTempDir(); const envPath = path.join(directory, ".env"); diff --git a/test/linux-docker-status.test.ts b/test/linux-docker-status.test.ts new file mode 100644 index 0000000..1b907aa --- /dev/null +++ b/test/linux-docker-status.test.ts @@ -0,0 +1,229 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import type { DeploymentEnv } from "../src/lib/env.js"; +import type { ResolvedLinuxDockerConfig } from "../src/lib/linux-docker-config.js"; +import { renderLinuxDockerCompose } from "../src/lib/linux-docker-compose.js"; +import { + buildLinuxDockerStatusReport, + formatLinuxDockerStatusText, + saveLinuxDockerResult +} from "../src/lib/linux-docker-status.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("linux docker status", () => { + test("summarizes saved install status and troubleshooting surfaces", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "linux-docker-status-")); + tempDirs.push(dir); + const resultPath = path.join(dir, "status.json"); + + saveLinuxDockerResult(resultPath, { + ok: true, + action: "up", + remoteLogPath: "/srv/github-runner-fleet/linux-docker/logs/install-project.log", + composePsOutput: "NAME STATUS\nrunner-01 Up 5 seconds", + connection: { + host: "docker-host.example.com", + port: "22", + username: "runner" + }, + project: { + name: "github-runner-fleet-linux-docker", + directory: "/srv/github-runner-fleet/linux-docker" + } + }); + + const env = envFixture(); + const report = buildLinuxDockerStatusReport({ + config: configFixture(), + env, + composeContent: renderLinuxDockerCompose(configFixture(), env), + savedResultPath: resultPath + }); + + expect(report.ok).toBe(true); + expect(report.savedResult?.ok).toBe(true); + expect(report.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "saved_result", ok: true }), + expect.objectContaining({ key: "recent_result", ok: true }), + expect.objectContaining({ key: "recent_log", ok: true }) + ]) + ); + const rendered = formatLinuxDockerStatusText(report); + expect(rendered).toContain("recent_action=up"); + expect(rendered).toContain("recent_compose_ps:"); + expect(rendered).toContain("troubleshooting:"); + }); + + test("reports missing prerequisites and missing saved result clearly", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "linux-docker-status-missing-")); + tempDirs.push(dir); + const missingResult = path.join(dir, "missing.json"); + const env = envFixture(); + env.githubPat = undefined; + env.linuxDockerHost = undefined; + env.linuxDockerUsername = undefined; + + const report = buildLinuxDockerStatusReport({ + config: configFixture(), + env, + composeContent: renderLinuxDockerCompose(configFixture(), env), + savedResultPath: missingResult + }); + + expect(report.ok).toBe(false); + expect(report.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "linux_docker_env", ok: false }), + expect.objectContaining({ key: "github_pat", ok: false }), + expect.objectContaining({ key: "saved_result_path", ok: false }) + ]) + ); + }); + + test("surfaces saved failure details ahead of generic troubleshooting", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "linux-docker-status-failed-")); + tempDirs.push(dir); + const resultPath = path.join(dir, "status.json"); + + saveLinuxDockerResult(resultPath, { + ok: false, + action: "down", + remoteLogPath: "/srv/github-runner-fleet/linux-docker/logs/install-project.log", + error: "docker compose down failed" + }); + + const env = envFixture(); + const report = buildLinuxDockerStatusReport({ + config: configFixture(), + env, + composeContent: renderLinuxDockerCompose(configFixture(), env), + savedResultPath: resultPath + }); + + expect(report.ok).toBe(false); + expect(report.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "recent_result", + ok: false, + summary: "latest Linux Docker down action failed" + }) + ]) + ); + expect(report.troubleshooting[0]).toEqual({ + symptom: "Latest saved install attempt failed", + nextStep: + "Start with /srv/github-runner-fleet/linux-docker/logs/install-project.log and the saved error: docker compose down failed" + }); + expect(formatLinuxDockerStatusText(report)).toContain( + "recent_error=docker compose down failed" + ); + }); + + test("allows config-only status without a saved result path", () => { + const env = envFixture(); + const report = buildLinuxDockerStatusReport({ + config: configFixture(), + env, + composeContent: renderLinuxDockerCompose(configFixture(), env) + }); + + expect(report.ok).toBe(true); + expect(report.savedResultPath).toBeUndefined(); + expect(report.savedResult).toBeUndefined(); + expect(report.checks).toContainEqual( + expect.objectContaining({ + key: "saved_result_path", + ok: true + }) + ); + expect(formatLinuxDockerStatusText(report)).not.toContain("recent_action="); + }); +}); + +function configFixture(): ResolvedLinuxDockerConfig { + return { + version: 1, + image: { + repository: "ghcr.io/example/github-runner-fleet", + tag: "0.1.9" + }, + pools: [ + { + key: "linux-docker-private", + visibility: "private", + organization: "example", + runnerGroup: "linux-docker-private", + repositoryAccess: "all", + allowedRepositories: [], + labels: ["linux", "docker-capable", "private", "x64"], + size: 1, + architecture: "amd64", + runnerRoot: "/srv/github-runner-fleet/linux-docker/pools/linux-docker-private", + resources: { + memory: "8g" + }, + imageRef: "ghcr.io/example/github-runner-fleet:0.1.9" + } + ] + }; +} + +function envFixture(): DeploymentEnv { + return { + githubPat: "test-pat", + githubApiUrl: "https://api.github.com", + synologyRunnerBaseDir: "/volume1/docker/github-runner-fleet", + synologyHost: "nas.example.com", + synologyPort: "5001", + synologyUsername: "admin", + synologyPassword: "secret", + synologySecure: true, + synologyCertVerify: false, + synologyDsmVersion: 7, + synologyApiRepo: "/Users/tester/src/synology-api", + synologyProjectDir: "/volume1/docker/github-runner-fleet", + synologyProjectComposeFile: "compose.yaml", + synologyProjectEnvFile: ".env", + synologyInstallPullImages: true, + synologyInstallForceRecreate: true, + synologyInstallRemoveOrphans: true, + linuxDockerRunnerBaseDir: "/srv/github-runner-fleet/linux-docker", + linuxDockerHost: "docker-host.example.com", + linuxDockerPort: "22", + linuxDockerUsername: "runner", + linuxDockerProjectDir: "/srv/github-runner-fleet/linux-docker", + linuxDockerProjectComposeFile: "compose.yaml", + linuxDockerProjectEnvFile: ".env", + linuxDockerInstallPullImages: true, + linuxDockerInstallForceRecreate: true, + linuxDockerInstallRemoveOrphans: true, + windowsDockerRunnerBaseDir: "C:\\github-runner-fleet\\windows", + windowsDockerHost: "windows-host.example.com", + windowsDockerPort: "22", + windowsDockerUsername: "runner", + windowsDockerProjectDir: "C:\\github-runner-fleet\\windows", + windowsDockerProjectComposeFile: "compose.yaml", + windowsDockerProjectEnvFile: ".env", + windowsDockerInstallPullImages: true, + windowsDockerInstallForceRecreate: true, + windowsDockerInstallRemoveOrphans: true, + lumeRunnerBaseDir: + "/Users/tester/Library/Application Support/github-runner-fleet/lume", + lumeRunnerEnvFile: + "/Users/tester/Library/Application Support/github-runner-fleet/lume/runner.env", + composeProjectName: "github-runner-fleet", + runnerVersion: "2.333.0", + raw: {} + }; +}