Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions k8s/miner-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <owner/repo> and <login> below -- see docker-compose.miner.yml's own comment and
# DEPLOYMENT.md's `loopover-miner loop` usage for the full flag set.
args: ["loop", "<owner/repo>", "--miner-login", "<login>"]
env:
- name: LOOPOVER_MINER_CONFIG_DIR
value: /data/miner
Expand Down
8 changes: 6 additions & 2 deletions packages/loopover-miner/docker-compose.miner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <owner/repo> and <login> below -- `loop` needs a real target (or `--search
# <query>` 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", "<owner/repo>", "--miner-login", "<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
Expand Down
51 changes: 51 additions & 0 deletions packages/loopover-miner/lib/deployment-docs-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ const NON_REPO_LINK_PATTERN = /^(?:https?:\/\/|mailto:|#|~|\/)/;
/** `cliArgs[0] === "<name>"` 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<string> {
const tokens = new Set<string>();
Expand Down Expand Up @@ -97,6 +101,53 @@ export function scanRegisteredCommands(binSource: string): Set<string> {
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", "<owner/repo>", ...]` -> `"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: <name>" instead of ever doing real work.
*/
export function auditContainerCommands(
claims: ContainerCommandClaim[],
reality: Pick<DeploymentDocsReality, "isRegisteredCommand">,
): 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<DeploymentDocsReality, "isRegisteredCommand">,
): 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
Expand Down
40 changes: 38 additions & 2 deletions scripts/check-miner-deployment-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <name>" (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
Expand Down Expand Up @@ -46,17 +57,30 @@ 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[];
claimCounts: {
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 = {
Expand All @@ -73,13 +97,23 @@ 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,
claimCounts: {
envVars: claims.envVars.length,
filePaths: claims.filePaths.length,
subcommands: claims.subcommands.length,
containerCommands: containerClaims.length,
},
};
}
Expand All @@ -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));
Expand Down
33 changes: 33 additions & 0 deletions test/unit/check-miner-deployment-docs.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
buildLiveContainerCommandClaims,
buildLiveMinerDeploymentReality,
main,
runMinerDeploymentDocsAudit,
Expand All @@ -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)", () => {
Expand Down
77 changes: 77 additions & 0 deletions test/unit/miner-deployment-docs-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", "<owner/repo>", "--miner-login", "<login>"]')).toBe("loop");
});

it("extractContainerCommandClaim reads a Kubernetes container `args:` list", () => {
expect(extractContainerCommandClaim(' args: ["loop", "<owner/repo>", "--miner-login", "<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: [] });
});
});
});
10 changes: 9 additions & 1 deletion test/unit/miner-docker-compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down
12 changes: 11 additions & 1 deletion test/unit/miner-k8s-manifests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, any>>;
const configDir = env.find((e) => e.name === "LOOPOVER_MINER_CONFIG_DIR");
expect(configDir?.value).toBe("/data/miner");
Expand All @@ -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<Record<string, any>>;
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");
Expand Down