diff --git a/k8s/miner-deployment.yaml b/k8s/miner-deployment.yaml index 698e185443..3bc60a807f 100644 --- a/k8s/miner-deployment.yaml +++ b/k8s/miner-deployment.yaml @@ -46,8 +46,11 @@ spec: - name: miner # Replace with the image you built + pushed from packages/loopover-miner/Dockerfile. image: loopover-miner:latest - # ENTRYPOINT is `loopover-miner`; `run` is the continuous fleet-worker loop. - args: ["run"] + # ENTRYPOINT is `loopover-miner`; `loop` is the continuous fleet-worker loop (`run` was never a + # registered subcommand -- see packages/loopover-miner/bin/loopover-miner.ts's dispatch table). + # REQUIRED: replace and below -- see docker-compose.miner.yml's own comment and + # DEPLOYMENT.md's `loopover-miner loop` usage for the full flag set. + args: ["loop", "", "--miner-login", ""] env: - name: LOOPOVER_MINER_CONFIG_DIR value: /data/miner diff --git a/packages/loopover-miner/docker-compose.miner.yml b/packages/loopover-miner/docker-compose.miner.yml index d05e5c7b15..566fabdcb8 100644 --- a/packages/loopover-miner/docker-compose.miner.yml +++ b/packages/loopover-miner/docker-compose.miner.yml @@ -26,8 +26,12 @@ services: dockerfile: packages/loopover-miner/Dockerfile image: loopover-miner:latest # ENTRYPOINT is egress-firewall-entrypoint.sh (#7857), which sets up the network-egress firewall as root - # then execs `loopover-miner` as the unprivileged `node` user; `run` is the continuous fleet-worker loop. - command: ["run"] + # then execs `loopover-miner` as the unprivileged `node` user; `loop` is the continuous fleet-worker loop + # (`run` was never a registered subcommand -- see packages/loopover-miner/bin/loopover-miner.ts's dispatch + # table). REQUIRED: replace and below -- `loop` needs a real target (or `--search + # ` instead of a repo target) and the GitHub login it commits/opens PRs as; see DEPLOYMENT.md's + # `loopover-miner loop` usage for the full flag set (e.g. `--live` to open real PRs instead of dry-run). + command: ["loop", "", "--miner-login", ""] restart: unless-stopped # #7857: NET_ADMIN/NET_RAW so the entrypoint's iptables/ipset egress-firewall setup can actually configure # rules -- same capability class this repo's own tailscale service already grants (docker-compose.yml), just diff --git a/packages/loopover-miner/lib/deployment-docs-audit.ts b/packages/loopover-miner/lib/deployment-docs-audit.ts index 54a9830f31..8df431cb78 100644 --- a/packages/loopover-miner/lib/deployment-docs-audit.ts +++ b/packages/loopover-miner/lib/deployment-docs-audit.ts @@ -44,6 +44,10 @@ const NON_REPO_LINK_PATTERN = /^(?:https?:\/\/|mailto:|#|~|\/)/; /** `cliArgs[0] === ""` guards in the miner bin — the CLI's registered top-level command table. */ const CLI_DISPATCH_PATTERN = /cliArgs\[0\]\s*===\s*"([a-z][a-z0-9-]*)"/g; +/** The first entry of a container manifest's `command:`/`args:` YAML list — the subcommand the image's + * ENTRYPOINT invokes the miner CLI with (docker-compose's `command:`, a Kubernetes container's `args:`). */ +const CONTAINER_COMMAND_PATTERN = /^\s*(?:command|args):\s*\[\s*"([a-z][a-z0-9-]*)"/m; + /** Collect every LOOPOVER_MINER_* / MINER_* token that appears in `text` (doc prose/code or source). */ export function scanEnvVarTokens(text: string): Set { const tokens = new Set(); @@ -97,6 +101,53 @@ export function scanRegisteredCommands(binSource: string): Set { return commands; } +/** A fleet-mode container manifest's claimed subcommand (docker-compose's `command:`, a Kubernetes + * container's `args:`), tagged with its source path so a drift failure names the exact file to fix. */ +export type ContainerCommandClaim = { source: string; command: string }; + +/** Extracts the subcommand a container manifest's `command:`/`args:` YAML list invokes the miner CLI with + * (e.g. `command: ["loop", "", ...]` -> `"loop"`). Returns null when the manifest text has no + * such list (e.g. a file whose command comes from the image's Dockerfile CMD instead). */ +export function extractContainerCommandClaim(manifestSource: string): string | null { + const match = CONTAINER_COMMAND_PATTERN.exec(manifestSource); + return match ? match[1]! : null; +} + +/** + * Cross-checks container-manifest command claims against the live registered-command set — the same + * `isRegisteredCommand` reality predicate `auditDeploymentDocs` uses for DEPLOYMENT.md's prose. This is the + * same drift class (a stale or never-valid subcommand baked into a deploy artifact), just sourced from + * fleet-mode YAML instead of markdown: a container built from a manifest whose command isn't registered sets + * up, then immediately exits non-zero with "Unknown command: " instead of ever doing real work. + */ +export function auditContainerCommands( + claims: ContainerCommandClaim[], + reality: Pick, +): DeploymentDocsAuditResult { + const failures: string[] = []; + for (const claim of claims) { + if (!reality.isRegisteredCommand(claim.command)) { + failures.push( + `container manifest "${claim.source}" invokes "loopover-miner ${claim.command}" but that subcommand is not registered in the CLI command table`, + ); + } + } + return { ok: failures.length === 0, failures }; +} + +/** Run `auditContainerCommands` and throw a build-failing error naming every stale claim; returns the result + * when every container manifest's command is genuinely registered. */ +export function assertContainerCommandsInSync( + claims: ContainerCommandClaim[], + reality: Pick, +): DeploymentDocsAuditResult { + const result = auditContainerCommands(claims, reality); + if (!result.ok) { + throw new Error(`Fleet-mode container manifests are out of sync with the miner CLI's registered commands:\n- ${result.failures.join("\n- ")}`); + } + return result; +} + /** * Cross-check parsed DEPLOYMENT.md claims against reality. `reality` supplies three predicates so this * comparison stays pure and filesystem-independent: `hasEnvRead(name)` (a read of that env var exists diff --git a/scripts/check-miner-deployment-docs.ts b/scripts/check-miner-deployment-docs.ts index f0133f0984..75dd9d2c3b 100644 --- a/scripts/check-miner-deployment-docs.ts +++ b/scripts/check-miner-deployment-docs.ts @@ -5,18 +5,29 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL, URL } from "node:url"; import { + assertContainerCommandsInSync, assertDeploymentDocsInSync, + extractContainerCommandClaim, extractEnvVarClaims, extractFilePathClaims, extractSubcommandClaims, scanEnvVarTokens, scanRegisteredCommands, + type ContainerCommandClaim, type DeploymentDocsReality, } from "../packages/loopover-miner/lib/deployment-docs-audit"; const REPO_ROOT = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); const MINER_DIR = resolve(REPO_ROOT, "packages/loopover-miner"); const DEPLOYMENT_MD = resolve(MINER_DIR, "DEPLOYMENT.md"); +// Fleet-mode container manifests whose command drifting from the real CLI dispatch table is the exact class +// DEPLOYMENT.md's own subcommand audit above already catches for prose -- a manifest built from a stale command +// sets up its container, then immediately exits 1 with "Unknown command: " (see docker-compose.miner.yml +// / k8s/miner-deployment.yaml's own comments for the `run`-was-never-registered regression this guards). +const CONTAINER_MANIFESTS: readonly { source: string; path: string }[] = [ + { source: "packages/loopover-miner/docker-compose.miner.yml", path: resolve(MINER_DIR, "docker-compose.miner.yml") }, + { source: "k8s/miner-deployment.yaml", path: resolve(REPO_ROOT, "k8s/miner-deployment.yaml") }, +]; // Scans the compiled dist/ output (2026-07-24 migration; see tsconfig.json's outDir comment) -- // this audit needs a prior `npm run build:miner`, same precondition as everything else that reads // real compiled artifacts (ci.yml's own "MCP/Miner package check" steps already run after "Build @@ -46,6 +57,17 @@ export function buildLiveMinerDeploymentReality(): DeploymentDocsReality { }; } +/** Build the live container-manifest command claims used by the audit (exported for unit tests): each fleet + * manifest in CONTAINER_MANIFESTS that has a `command:`/`args:` list, paired with the subcommand it claims. */ +export function buildLiveContainerCommandClaims(): ContainerCommandClaim[] { + const claims: ContainerCommandClaim[] = []; + for (const manifest of CONTAINER_MANIFESTS) { + const command = extractContainerCommandClaim(readFileSync(manifest.path, "utf8")); + if (command !== null) claims.push({ source: manifest.source, command }); + } + return claims; +} + export type MinerDeploymentAuditResult = { ok: boolean; failures: string[]; @@ -53,10 +75,12 @@ export type MinerDeploymentAuditResult = { envVars: number; filePaths: number; subcommands: number; + containerCommands: number; }; }; -/** Run the live DEPLOYMENT.md audit. */ +/** Run the live DEPLOYMENT.md audit, plus the fleet-mode container-manifest command audit (same drift class, + * a different claim source — see docker-compose.miner.yml / k8s/miner-deployment.yaml's own comments). */ export function runMinerDeploymentDocsAudit(opts: { testMode?: string | null; reality?: DeploymentDocsReality } = {}): MinerDeploymentAuditResult { const markdown = readFileSync(DEPLOYMENT_MD, "utf8"); const claims = { @@ -73,6 +97,15 @@ export function runMinerDeploymentDocsAudit(opts: { testMode?: string | null; re }; } const result = assertDeploymentDocsInSync(claims, reality); + + const containerClaims = buildLiveContainerCommandClaims(); + // Test-only fixture (mirrors "missing-env" above): forces every container-manifest command "unregistered" + // without touching `reality`, so this drift class is exercised independently of the DEPLOYMENT.md audit + // above -- both currently claim `loop`, so reusing one shared override would mask which audit actually caught + // the regression. + const containerReality = opts.testMode === "bad-container-command" ? { ...reality, isRegisteredCommand: () => false } : reality; + assertContainerCommandsInSync(containerClaims, containerReality); + return { ok: result.ok, failures: result.failures, @@ -80,6 +113,7 @@ export function runMinerDeploymentDocsAudit(opts: { testMode?: string | null; re envVars: claims.envVars.length, filePaths: claims.filePaths.length, subcommands: claims.subcommands.length, + containerCommands: containerClaims.length, }, }; } @@ -102,7 +136,9 @@ export function main( const result = runMinerDeploymentDocsAudit({ testMode: env.CHECK_MINER_DEPLOYMENT_DOCS_AUDIT_TEST_MODE ?? null, }); - io.log(`Miner deployment docs audit ok: ${result.claimCounts.envVars} env vars, ${result.claimCounts.filePaths} paths, ${result.claimCounts.subcommands} subcommands.`); + io.log( + `Miner deployment docs audit ok: ${result.claimCounts.envVars} env vars, ${result.claimCounts.filePaths} paths, ${result.claimCounts.subcommands} subcommands, ${result.claimCounts.containerCommands} container commands.`, + ); return 0; } catch (error) { io.error(error instanceof Error ? error.message : String(error)); diff --git a/test/unit/check-miner-deployment-docs.test.ts b/test/unit/check-miner-deployment-docs.test.ts index dc47408aa1..a2a376401e 100644 --- a/test/unit/check-miner-deployment-docs.test.ts +++ b/test/unit/check-miner-deployment-docs.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + buildLiveContainerCommandClaims, buildLiveMinerDeploymentReality, main, runMinerDeploymentDocsAudit, @@ -13,6 +14,38 @@ describe("check-miner-deployment-docs (#6158)", () => { expect(result.claimCounts.envVars).toBeGreaterThan(0); expect(result.claimCounts.filePaths).toBeGreaterThan(0); expect(result.claimCounts.subcommands).toBeGreaterThan(0); + // Both fleet-mode manifests (docker-compose.miner.yml, k8s/miner-deployment.yaml) have a genuine + // command/args claim, proving the container-manifest audit actually read and parsed real files rather + // than trivially passing on an empty claim set. + expect(result.claimCounts.containerCommands).toBe(2); + }); + + it("buildLiveContainerCommandClaims reads both fleet-mode manifests' real commands", () => { + const claims = buildLiveContainerCommandClaims(); + expect(claims).toEqual([ + { source: "packages/loopover-miner/docker-compose.miner.yml", command: "loop" }, + { source: "k8s/miner-deployment.yaml", command: "loop" }, + ]); + }); + + it("fails when a fleet-mode container manifest's command is forced unregistered (drift fixture)", () => { + expect(() => runMinerDeploymentDocsAudit({ testMode: "bad-container-command" })).toThrow( + /Fleet-mode container manifests are out of sync/i, + ); + }); + + it("main exits non-zero on the forced-unregistered-container-command drift fixture", () => { + const exit = vi.fn(); + const error = vi.fn(); + const log = vi.fn(); + const code = main( + { CHECK_MINER_DEPLOYMENT_DOCS_AUDIT_TEST_MODE: "bad-container-command" }, + { log, error, exit }, + ); + expect(code).toBe(1); + expect(exit).toHaveBeenCalledWith(1); + expect(error).toHaveBeenCalledWith(expect.stringMatching(/Fleet-mode container manifests are out of sync/i)); + expect(log).not.toHaveBeenCalled(); }); it("exposes the live env-var read set as an enumerable field for the reverse audit (#6601)", () => { diff --git a/test/unit/miner-deployment-docs-audit.test.ts b/test/unit/miner-deployment-docs-audit.test.ts index 6a9e309d12..037eb500d1 100644 --- a/test/unit/miner-deployment-docs-audit.test.ts +++ b/test/unit/miner-deployment-docs-audit.test.ts @@ -3,8 +3,11 @@ import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { + assertContainerCommandsInSync, assertDeploymentDocsInSync, + auditContainerCommands, auditDeploymentDocs, + extractContainerCommandClaim, extractEnvVarClaims, extractFilePathClaims, extractSubcommandClaims, @@ -241,4 +244,78 @@ describe("loopover-miner DEPLOYMENT.md docs-accuracy audit (#5180)", () => { expect(result.ok).toBe(true); expect(result.failures).toEqual([]); }); + + describe("container-manifest command audit (fleet-mode `run`-vs-`loop` drift)", () => { + it("extractContainerCommandClaim reads a docker-compose `command:` list", () => { + expect(extractContainerCommandClaim('command: ["loop", "", "--miner-login", ""]')).toBe("loop"); + }); + + it("extractContainerCommandClaim reads a Kubernetes container `args:` list", () => { + expect(extractContainerCommandClaim(' args: ["loop", "", "--miner-login", ""]')).toBe("loop"); + }); + + it("extractContainerCommandClaim returns null when the manifest has no command/args list", () => { + expect(extractContainerCommandClaim("image: loopover-miner:latest\nrestart: unless-stopped\n")).toBeNull(); + }); + + it("REGRESSION: extractContainerCommandClaim would have caught the never-registered `run` claim", () => { + // The exact stale value docker-compose.miner.yml and k8s/miner-deployment.yaml both shipped from their + // very first commits (#5299, #5258) -- `run` was never dispatched by the miner CLI. + expect(extractContainerCommandClaim('command: ["run"]')).toBe("run"); + expect(extractContainerCommandClaim('args: ["run"]')).toBe("run"); + }); + + it("auditContainerCommands reports ok when every manifest's command is registered", () => { + const result = auditContainerCommands( + [{ source: "docker-compose.miner.yml", command: "loop" }], + { isRegisteredCommand: () => true }, + ); + expect(result).toEqual({ ok: true, failures: [] }); + }); + + it("flags a container manifest whose command is not registered in the CLI", () => { + const result = auditContainerCommands( + [{ source: "docker-compose.miner.yml", command: "run" }], + { isRegisteredCommand: () => false }, + ); + expect(result.ok).toBe(false); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]).toContain("docker-compose.miner.yml"); + expect(result.failures[0]).toContain("loopover-miner run"); + expect(result.failures[0]).toContain("not registered"); + }); + + it("auditContainerCommands names every stale manifest, not just the first", () => { + const result = auditContainerCommands( + [ + { source: "docker-compose.miner.yml", command: "run" }, + { source: "k8s/miner-deployment.yaml", command: "run" }, + ], + { isRegisteredCommand: () => false }, + ); + expect(result.failures).toHaveLength(2); + }); + + it("assertContainerCommandsInSync throws naming the stale manifest and command", () => { + expect(() => + assertContainerCommandsInSync([{ source: "k8s/miner-deployment.yaml", command: "run" }], { isRegisteredCommand: () => false }), + ).toThrow(/k8s\/miner-deployment\.yaml[\s\S]*loopover-miner run/); + }); + + it("assertContainerCommandsInSync returns the ok result without throwing when in sync", () => { + const result = assertContainerCommandsInSync([{ source: "docker-compose.miner.yml", command: "loop" }], { isRegisteredCommand: () => true }); + expect(result.ok).toBe(true); + expect(result.failures).toEqual([]); + }); + + it("live fleet-mode manifests both invoke the real registered `loop` daemon, not `run`", () => { + const reality = buildLiveReality(); + const claims = [ + { source: "docker-compose.miner.yml", command: extractContainerCommandClaim(readFileSync(join(MINER_DIR, "docker-compose.miner.yml"), "utf8"))! }, + { source: "k8s/miner-deployment.yaml", command: extractContainerCommandClaim(readFileSync(resolve(REPO_ROOT, "k8s/miner-deployment.yaml"), "utf8"))! }, + ]; + expect(claims.map((c) => c.command)).toEqual(["loop", "loop"]); + expect(assertContainerCommandsInSync(claims, reality)).toEqual({ ok: true, failures: [] }); + }); + }); }); diff --git a/test/unit/miner-docker-compose.test.ts b/test/unit/miner-docker-compose.test.ts index 8f748629ba..f8732539e9 100644 --- a/test/unit/miner-docker-compose.test.ts +++ b/test/unit/miner-docker-compose.test.ts @@ -22,7 +22,15 @@ describe("docker-compose.miner.yml (#5177)", () => { expect(miner).toBeTruthy(); expect(miner.build.dockerfile).toBe("packages/loopover-miner/Dockerfile"); expect(miner.build.context).toBe("../.."); - expect(miner.command).toContain("run"); + expect(miner.command).toContain("loop"); + }); + + it("REGRESSION: does not invoke `run`, which was never a registered miner subcommand", () => { + // `run` shipped in this file's very first commit (#5299) and was never dispatched by + // packages/loopover-miner/bin/loopover-miner.ts -- a fleet worker built from this compose file set up the + // egress firewall, then immediately exited 1 with "Unknown command: run." `loop` (#5303) is the real + // continuous fleet-worker daemon (see loop-cli.ts / DEPLOYMENT.md's "loopover-miner loop" usage). + expect(compose.services.miner.command).not.toContain("run"); }); it("tags the built image with the post-rename name (loopover-miner:latest)", () => { diff --git a/test/unit/miner-k8s-manifests.test.ts b/test/unit/miner-k8s-manifests.test.ts index c035c7e405..07bc97eed9 100644 --- a/test/unit/miner-k8s-manifests.test.ts +++ b/test/unit/miner-k8s-manifests.test.ts @@ -67,7 +67,7 @@ describe("k8s miner manifests (#5181)", () => { const container = containers[0]; if (!container) throw new Error("no container in the StatefulSet pod template"); - expect(container.args).toContain("run"); + expect(container.args).toContain("loop"); const env = container.env as Array>; const configDir = env.find((e) => e.name === "LOOPOVER_MINER_CONFIG_DIR"); expect(configDir?.value).toBe("/data/miner"); @@ -77,6 +77,16 @@ describe("k8s miner manifests (#5181)", () => { expect(container.resources.limits).toBeTruthy(); }); + it("REGRESSION: does not invoke `run`, which was never a registered miner subcommand", () => { + // `run` shipped in this manifest's very first commit (#5258) and was never dispatched by + // packages/loopover-miner/bin/loopover-miner.ts -- a StatefulSet built from this manifest would set up + // (or, pre-#8282, skip) the pod, then immediately exit 1 with "Unknown command: run." `loop` (#5303) is the + // real continuous fleet-worker daemon (see loop-cli.ts / DEPLOYMENT.md's "loopover-miner loop" usage). + const containers = (deployment.spec as any).template.spec + .containers as Array>; + expect(containers[0]?.args).not.toContain("run"); + }); + it("secret template is a well-formed Secret exposing GITHUB_TOKEN", () => { const secret = readManifest("miner-secret.example.yaml"); expect(validateK8sResource(secret).kind).toBe("Secret");