mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
feat(slice-05): /install conflict gate + CLI --force override (Item 3 Checkpoint 4.2) (OPR.0.3.2.5)
Integrates the bundle-conflict-detector from5b00abf7(Item 3 Checkpoint 4.1) into the /api/bundles/install pre-delegation gate. Bundles whose rig name collides with a currently-running rig fail with the 3-part error shape (error + conflicts[] + resolutions[]) BEFORE bootstrap delegation. Operator override via --force (request body force=true). Ordering preserved: lock acquisition → compat check (Item 2) → conflict check (Item 3) → bootstrap delegation. The conflict check runs from the same safe-extract pass as compat (one unpack per install), so the unpack-safety boundary holds for both checks. Helper rename: extractManifestForCompatCheck wraps a new extractInstallTimeMetadata helper that returns both bundleManifest (for compat) and rigName (from the bundle's rig.yaml, for conflict). Old helper kept as a thin shim so callers don't churn; new helper is the single-pass safe-extract path. The compat extraction failure shape is generalized to cover both pre-checks. Files touched: - packages/daemon/src/routes/bundles.ts: import detectBundleConflicts + RigRepository type; new extractInstallTimeMetadata helper (single-pass extract returning bundleManifest + rigName via safe unpack — banked safety-boundary lesson applied); getDeps extended with rigRepo from Hono context; /install reads force from body and invokes detectBundleConflicts after the compat check, mapping RigRepository.Rig.id -> detector input rigId (guard 4.1 watchpoint applied); 3-part conflict error returned with 400 status; fallback extraction-failure shape generalized to cover both pre-checks - packages/cli/src/commands/bundle.ts: new --force flag on install; force field included in /api/bundles/install request body - packages/daemon/test/bundle-routes.test.ts: 3 new tests — conflict detected with running rig of same name (3-part shape asserted); force=true bypasses on same conflict; no conflict when no rig name matches - packages/cli/test/bundle.test.ts: 2 new tests — --force sets force=true in body; no flag sets force=false (operator-explicit opt-in only) Diff: 4 files, +211 / -25 lines. Verified: - 113/113 daemon bundle suite green (was 110; +3 new conflict route tests) - 19/19 CLI bundle suite green (was 17; +2 new --force flag tests) - 132/132 total bundle tests across both packages - scope-and-versioning sections 1 and 3 coherence re-audit clean - audit-every-layer: zero new module-level constants reading env/host/version (detector pure; helper reads from extracted manifest; route reads from rigRepo via DI; CLI reads opts from commander) Pre-commit discipline applied (banked lesson from5b00abf7incident): git diff --cached --stat inspected after explicit git add of the 4 intended files; staged scope confirmed to match intent BEFORE commit. No unexpected staged content swept into this commit. PRD bounce conditions verified: - No schema bump - No new SQLite migrations - No event types - No core-routing or bootstrap-orchestrator mutation (bootstrap delegation unchanged; conflict check is a pre-delegation gate in the route handler only) - No sidebar entries touched - Extends, does NOT replace, the existing 409 / "install already in progress" concurrency lock; the new check is additive Watched risks applied: - Audit-every-layer: zero new module-level constants. detector reads from pure input; helper reads from extracted manifest; route reads rigRepo via DI; CLI reads from commander opts - Optional-field backward-compat: rigName undefined (bundle has no rig.yaml or no name field) → detector fail-opens (no conflict to compare); pre-Item-3 CLIs that don't send force → force defaults to false (conflict check runs as designed); force=true is the operator-explicit opt-in NOT the silent default - Safety boundary preserved: single unpack call shared between compat + conflict pre-checks; banked lesson from Item 2 B1 applied Conflict kinds NOT in this commit (graduate to Checkpoint 4.3): - Agent-name collisions (v2 pod-aware: per-rig agent set check) - Port collisions (where bundle declares ports) - Managed-app project-name collisions - Sibling-primitive collisions (fold with Item 6 cross-primitive bundling) CLI --target rename semantic NOT in this commit: PRD spec used --target <newname> for rename, but the existing CLI install --target already ships as --target <root> (targetRoot). Resolving that flag- name collision is a separable design decision; surfaced as a follow-up question rather than introducing a breaking CLI change mid-slice. Item 3 sub-chain on this branch: -97803a8f(Item 3 4.1 detector + CLI restore, CLEAR) <- prior tip - THIS COMMIT — Item 3 part 2: /install conflict gate + --force - Next (4.3) — agent + port + managed-app collisions - Next (4.4) — sibling-primitive collisions (folds with Item 6) Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
97803a8fb3
commit
8752bfe972
4 files changed
+211
-25
No files matched your search
@@ -141,8 +141,9 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--yes", "Auto-approve")
|
||||
.option("--target <root>", "Target root directory")
|
||||
.option("--skip-version-check", "Operator-explicit override of the Item-2 install-time compatibility check (NOT recommended for routine use)")
|
||||
.option("--force", "Operator-explicit override of the Item-3 install-time conflict check (NOT recommended; conflicts may produce partial install state)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (bundlePath: string, opts: { plan?: boolean; yes?: boolean; target?: string; skipVersionCheck?: boolean; json?: boolean }) => {
|
||||
.action(async (bundlePath: string, opts: { plan?: boolean; yes?: boolean; target?: string; skipVersionCheck?: boolean; force?: boolean; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
@@ -154,6 +155,9 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
// time (no module-level constant) via the existing getCliVersion helper.
|
||||
cliVersion: getCliVersion(),
|
||||
skipVersionCheck: opts.skipVersionCheck ?? false,
|
||||
// Item 3 / slice-05 Checkpoint 4.2: send force flag for the daemon-side
|
||||
// install-time conflict check. Operator-explicit override only.
|
||||
force: opts.force ?? false,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
|
||||
@@ -323,6 +323,34 @@ describe("Bundle CLI", () => {
|
||||
expect(typeof installBody!["cliVersion"]).toBe("string");
|
||||
});
|
||||
|
||||
// Item 3 / slice-05 Checkpoint 4.2: --force flag wires through to request body
|
||||
it("bundle install --force sets force=true in request body", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
"--force",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["force"]).toBe(true);
|
||||
});
|
||||
|
||||
it("bundle install without --force sets force=false in request body (operator-explicit opt-in only)", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["force"]).toBe(false);
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: no flags → compatibility omitted (no empty object sent)
|
||||
it("bundle create with neither min-version flag omits compatibility from request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
|
||||
@@ -17,6 +17,8 @@ import { RigSpecSchema } from "../domain/rigspec-schema.js";
|
||||
import { parseLegacyBundleManifest as parseBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest, serializePodBundleManifest, parsePodBundleManifest, validatePodBundleManifest, normalizeProvenanceBlock, normalizeCompatibilityBlock } from "../domain/bundle-types.js";
|
||||
import type { PodBundleManifest, BundleProvenance, BundleCompatibility } from "../domain/bundle-types.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { detectBundleConflicts, type BundleConflict } from "../domain/bundle-conflict-detector.js";
|
||||
import type { RigRepository } from "../domain/rig-repository.js";
|
||||
|
||||
/**
|
||||
* Read the daemon's own package.json version at call time (Item 1 / slice-05).
|
||||
@@ -105,14 +107,46 @@ function checkBundleCompatibility(
|
||||
* / parse failures; the caller converts these into a 3-part 400 response.
|
||||
*/
|
||||
async function extractManifestForCompatCheck(bundlePath: string): Promise<Record<string, unknown>> {
|
||||
const tmpDir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-compat-"));
|
||||
const meta = await extractInstallTimeMetadata(bundlePath);
|
||||
return meta.bundleManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract both the bundle.yaml manifest AND the rig name from the bundle's
|
||||
* rig.yaml in one safe pass (Item 3 / slice-05 Checkpoint 4.2). The /install
|
||||
* handler uses bundleManifest for the compat check (Item 2) and rigName for
|
||||
* the conflict check (Item 3). Reuses unpack — single trust boundary.
|
||||
*/
|
||||
async function extractInstallTimeMetadata(bundlePath: string): Promise<{
|
||||
bundleManifest: Record<string, unknown>;
|
||||
rigName: string | undefined;
|
||||
}> {
|
||||
const tmpDir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-meta-"));
|
||||
try {
|
||||
await unpack(bundlePath, tmpDir);
|
||||
const manifestPath = nodePath.join(tmpDir, "bundle.yaml");
|
||||
if (!fs.existsSync(manifestPath)) throw new Error("Bundle missing bundle.yaml");
|
||||
const manifestYaml = fs.readFileSync(manifestPath, "utf-8");
|
||||
const parsed = parsePodBundleManifest(manifestYaml);
|
||||
return parsed as Record<string, unknown>;
|
||||
const bundleManifest = parsePodBundleManifest(manifestYaml) as Record<string, unknown>;
|
||||
// Rig name lives in the bundle's rig.yaml (path referenced by bundle.yaml's
|
||||
// rig_spec field; defaults to rig.yaml for legacy bundles). Read + parse;
|
||||
// missing-or-malformed rig name leaves rigName undefined which the detector
|
||||
// fail-opens on (no rig name to compare).
|
||||
let rigName: string | undefined;
|
||||
const rigSpecRel = typeof bundleManifest["rig_spec"] === "string" ? bundleManifest["rig_spec"] : "rig.yaml";
|
||||
const rigSpecPath = nodePath.join(tmpDir, rigSpecRel);
|
||||
if (fs.existsSync(rigSpecPath)) {
|
||||
try {
|
||||
const rigYaml = fs.readFileSync(rigSpecPath, "utf-8");
|
||||
const rigParsed = parsePodBundleManifest(rigYaml) as Record<string, unknown>;
|
||||
if (typeof rigParsed["name"] === "string" && rigParsed["name"].length > 0) {
|
||||
rigName = rigParsed["name"];
|
||||
}
|
||||
} catch {
|
||||
// rig.yaml malformed — leave rigName undefined; conflict check skips
|
||||
}
|
||||
}
|
||||
return { bundleManifest, rigName };
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -160,6 +194,7 @@ function getDeps(c: { get: (key: string) => unknown }) {
|
||||
eventBus: c.get("eventBus" as never) as EventBus,
|
||||
bootstrapOrchestrator: c.get("bootstrapOrchestrator" as never) as BootstrapOrchestrator,
|
||||
bootstrapRepo: c.get("bootstrapRepo" as never) as BootstrapRepository,
|
||||
rigRepo: c.get("rigRepo" as never) as RigRepository | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -426,7 +461,7 @@ bundleRoutes.post("/inspect", async (c) => {
|
||||
|
||||
// POST /api/bundles/install — reuses full bootstrap lifecycle
|
||||
bundleRoutes.post("/install", async (c) => {
|
||||
const { bootstrapOrchestrator, bootstrapRepo, eventBus } = getDeps(c);
|
||||
const { bootstrapOrchestrator, bootstrapRepo, eventBus, rigRepo } = getDeps(c);
|
||||
const body: Record<string, unknown> = await c.req.json().catch(() => ({}));
|
||||
const bundlePath = typeof body["bundlePath"] === "string" ? body["bundlePath"] : "";
|
||||
const plan = body["plan"] === true;
|
||||
@@ -435,6 +470,8 @@ bundleRoutes.post("/install", async (c) => {
|
||||
// Item 2 / slice-05 Checkpoint 3.3: install-time compatibility check inputs
|
||||
const skipVersionCheck = body["skipVersionCheck"] === true;
|
||||
const clientCliVersion = typeof body["cliVersion"] === "string" ? body["cliVersion"] : undefined;
|
||||
// Item 3 / slice-05 Checkpoint 4.2: install-time conflict check inputs
|
||||
const force = body["force"] === true;
|
||||
|
||||
if (!bundlePath) return c.json({ error: "bundlePath is required" }, 400);
|
||||
if (!plan && !targetRoot) return c.json({ error: "targetRoot is required for apply mode" }, 400);
|
||||
@@ -451,32 +488,61 @@ bundleRoutes.post("/install", async (c) => {
|
||||
// 3-part error and exits the lifecycle (lock releases via the outer
|
||||
// finally). Operator override via --skip-version-check (request body
|
||||
// skipVersionCheck=true).
|
||||
if (!skipVersionCheck) {
|
||||
// Item 2 + Item 3 / slice-05: single safe extract pass yields both the
|
||||
// bundle manifest (for compat check) and the rig name (for conflict check).
|
||||
// Caller can skip the compat check via skipVersionCheck; the conflict check
|
||||
// also runs from this same extract pass unless --force bypasses it.
|
||||
let installMeta: { bundleManifest: Record<string, unknown>; rigName: string | undefined } | null = null;
|
||||
if (!skipVersionCheck || !force) {
|
||||
try {
|
||||
const manifest = await extractManifestForCompatCheck(bundlePath);
|
||||
const compatibility = normalizeCompatibilityBlock(manifest["compatibility"]);
|
||||
const failures = checkBundleCompatibility(compatibility, getDaemonVersion(), clientCliVersion);
|
||||
if (failures) {
|
||||
return c.json({
|
||||
error: "Bundle compatibility check failed",
|
||||
failures,
|
||||
resolutions: [
|
||||
"upgrade the affected runtime to the required version (recommended)",
|
||||
"use a bundle with relaxed minimum requirements",
|
||||
"pass --skip-version-check to bypass for an operator-explicit override (NOT recommended for routine use)",
|
||||
],
|
||||
}, 400);
|
||||
}
|
||||
installMeta = await extractInstallTimeMetadata(bundlePath);
|
||||
} catch (err) {
|
||||
// Extraction failure here is treated as honest fast-fail rather than
|
||||
// silently skipping the check; bootstrap would surface the same error
|
||||
// anyway. 3-part shape preserved.
|
||||
return c.json({
|
||||
error: "Bundle compatibility check could not run (extraction failed)",
|
||||
error: "Bundle install pre-check could not run (extraction failed)",
|
||||
detail: (err as Error).message,
|
||||
resolutions: [
|
||||
"confirm the bundle path is correct and the archive is readable",
|
||||
"pass --skip-version-check to bypass the check (NOT recommended unless intentional)",
|
||||
"pass --skip-version-check and --force to bypass both pre-checks (NOT recommended unless intentional)",
|
||||
],
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipVersionCheck && installMeta) {
|
||||
const compatibility = normalizeCompatibilityBlock(installMeta.bundleManifest["compatibility"]);
|
||||
const failures = checkBundleCompatibility(compatibility, getDaemonVersion(), clientCliVersion);
|
||||
if (failures) {
|
||||
return c.json({
|
||||
error: "Bundle compatibility check failed",
|
||||
failures,
|
||||
resolutions: [
|
||||
"upgrade the affected runtime to the required version (recommended)",
|
||||
"use a bundle with relaxed minimum requirements",
|
||||
"pass --skip-version-check to bypass for an operator-explicit override (NOT recommended for routine use)",
|
||||
],
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Item 3 / slice-05 Checkpoint 4.2: install-time conflict check
|
||||
// Runs AFTER the compat check + BEFORE bootstrap delegation. Mismatch
|
||||
// returns a 400 with the 3-part error shape (error + conflicts[] +
|
||||
// resolutions[]). Operator override via --force (request body force=true).
|
||||
// The check fails CLOSED on extraction failure (handled above) and
|
||||
// fail-OPEN on missing rig name in the bundle (no rig name to compare).
|
||||
if (!force && installMeta && rigRepo) {
|
||||
const runningRigs = rigRepo.listRigs().map((r) => ({ rigId: r.id, name: r.name }));
|
||||
const report = detectBundleConflicts({
|
||||
bundleRigName: installMeta.rigName ?? "",
|
||||
runningRigs,
|
||||
});
|
||||
if (report.hasConflicts) {
|
||||
return c.json({
|
||||
error: "Bundle install conflict check failed",
|
||||
conflicts: report.conflicts,
|
||||
resolutions: [
|
||||
"stop the conflicting running rig and re-attempt install",
|
||||
"use --force to bypass for an operator-explicit override (NOT recommended for routine use; conflicts may produce partial install state)",
|
||||
],
|
||||
}, 400);
|
||||
}
|
||||
|
||||
@@ -820,6 +820,94 @@ describe("Bundle API routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Item 3 / slice-05 Checkpoint 4.2: install conflict gate. Conflict path:
|
||||
// a running rig with the same name as the bundle's rig must produce a
|
||||
// 3-part error response from /install BEFORE bootstrap delegation.
|
||||
// Force-bypass path: same bundle with force=true must skip the conflict
|
||||
// check.
|
||||
it("POST /api/bundles/install fails with 3-part conflict error when bundle rig name collides with a running rig", async () => {
|
||||
const { specPath } = seedPackage();
|
||||
const bundlePath = path.join(tmpDir, "conflict-test.rigbundle");
|
||||
|
||||
// Create bundle whose rig.yaml declares name 'test-rig' (matches VALID_SPEC)
|
||||
const createRes = await app.request("/api/bundles/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ specPath, bundleName: "conflict-test", bundleVersion: "0.1.0", outputPath: bundlePath }),
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
|
||||
// Seed a running rig with the same name as the bundle's rig
|
||||
setup.rigRepo.createRig("test-rig");
|
||||
|
||||
const installRes = await app.request("/api/bundles/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bundlePath, plan: true }),
|
||||
});
|
||||
expect(installRes.status).toBe(400);
|
||||
const body = await installRes.json();
|
||||
expect(body.error).toBe("Bundle install conflict check failed");
|
||||
expect(Array.isArray(body.conflicts)).toBe(true);
|
||||
expect(body.conflicts.length).toBeGreaterThan(0);
|
||||
const rigConflict = body.conflicts.find((c: { kind: string }) => c.kind === "rig_name_collision");
|
||||
expect(rigConflict).toBeDefined();
|
||||
expect(rigConflict.bundleRigName).toBe("test-rig");
|
||||
expect(typeof rigConflict.collisionWith.rigId).toBe("string");
|
||||
expect(rigConflict.collisionWith.rigName).toBe("test-rig");
|
||||
expect(Array.isArray(body.resolutions)).toBe(true);
|
||||
expect(body.resolutions.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("POST /api/bundles/install with force=true bypasses the conflict check on a name collision", async () => {
|
||||
const { specPath } = seedPackage();
|
||||
const bundlePath = path.join(tmpDir, "conflict-force.rigbundle");
|
||||
|
||||
await app.request("/api/bundles/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ specPath, bundleName: "conflict-force", bundleVersion: "0.1.0", outputPath: bundlePath }),
|
||||
});
|
||||
|
||||
setup.rigRepo.createRig("test-rig");
|
||||
|
||||
const installRes = await app.request("/api/bundles/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bundlePath, plan: true, force: true }),
|
||||
});
|
||||
// Conflict check was bypassed. Bootstrap may return any status; what we
|
||||
// assert is the absence of the conflict-check failure shape.
|
||||
if (installRes.status === 400) {
|
||||
const body = await installRes.json();
|
||||
expect(body.error).not.toBe("Bundle install conflict check failed");
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /api/bundles/install passes the conflict check when no running rig matches the bundle's rig name", async () => {
|
||||
const { specPath } = seedPackage();
|
||||
const bundlePath = path.join(tmpDir, "no-conflict.rigbundle");
|
||||
|
||||
await app.request("/api/bundles/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ specPath, bundleName: "no-conflict", bundleVersion: "0.1.0", outputPath: bundlePath }),
|
||||
});
|
||||
|
||||
// No rigs created — running set is empty
|
||||
|
||||
const installRes = await app.request("/api/bundles/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bundlePath, plan: true }),
|
||||
});
|
||||
// Must NOT be the conflict-fail shape. Bootstrap may return any status.
|
||||
if (installRes.status === 400) {
|
||||
const body = await installRes.json();
|
||||
expect(body.error).not.toBe("Bundle install conflict check failed");
|
||||
}
|
||||
});
|
||||
|
||||
// T11: Install concurrency lock
|
||||
it("concurrent bundle install returns 409", async () => {
|
||||
// Acquire lock manually
|
||||
|
||||
Reference in new issue
Block a user