|
|
|
@@ -11,7 +11,7 @@ import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
|
|
|
|
|
|
|
|
|
const cleanup: Array<() => void> = [];
|
|
|
|
|
afterEach(() => { for (const close of cleanup.splice(0).reverse()) close(); });
|
|
|
|
|
async function fixture(attached = false, automatic = true) {
|
|
|
|
|
async function fixture(attached = false, automatic = true, beforeGeneration = false) {
|
|
|
|
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "environment-lifecycle-"))); cleanup.push(() => rmSync(root, { recursive: true, force: true }));
|
|
|
|
|
const db = createFullTestDb(); cleanup.push(() => db.close());
|
|
|
|
|
const resource = { id: "compute-one", name: "worker-one", workspace: "workspace", phase: "Ready", operationId: "create-one" };
|
|
|
|
@@ -40,16 +40,53 @@ async function fixture(attached = false, automatic = true) {
|
|
|
|
|
const rig = f.rigRepo.createRig("lifecycle"), node = f.rigRepo.addNode(rig.id, "worker", { runtime: "openshell-claude", cwd: root });
|
|
|
|
|
const request = async (path: string, body?: unknown) => { const res = await f.app.request(`/api/environments${path}`, { method: body ? "POST" : "GET", headers: { "content-type": "application/json", "x-openrig-session": "coordinator@lifecycle" }, ...(body ? { body: JSON.stringify(body) } : {}) }); return { status: res.status, body: await res.json() as Record<string, any> }; };
|
|
|
|
|
const policy = join(root, "policy.yaml"); writeFileSync(policy, "version: 1\n");
|
|
|
|
|
const input = { version: 1, bindingId: "binding-one", operationId: "create-one", nodeId: node.id, seat: "worker@lifecycle", baseRuntime: "claude-code", guestRoot: "/sandbox/openrig", guestBaseUrl: "http://guest.test:7433", placement: { gateway: "gateway", workspace: "workspace", name: "worker-one", image: `image@sha256:${"a".repeat(64)}`, provider: "provider", policy } };
|
|
|
|
|
const input = { version: 1, bindingId: "binding-one", operationId: "create-one", nodeId: beforeGeneration ? undefined : node.id, seat: "worker@lifecycle", baseRuntime: "claude-code", guestRoot: "/sandbox/openrig", guestBaseUrl: "http://guest.test:7433", placement: { gateway: "gateway", workspace: "workspace", name: "worker-one", image: `image@sha256:${"a".repeat(64)}`, provider: "provider", policy } };
|
|
|
|
|
const created = await request(attached ? "/attach" : "/create", { ...input, ...(attached ? { resourceId: resource.id } : {}) }); expect(created.status, JSON.stringify(created.body)).toBeLessThan(300);
|
|
|
|
|
if (!beforeGeneration) {
|
|
|
|
|
db.prepare("UPDATE environment_bindings SET generation='generation-one' WHERE binding_id='binding-one'").run();
|
|
|
|
|
const receipt = { version: 1, bindingId: "binding-one", nodeId: node.id, resourceId: resource.id, ...native, activityGrantId: "grant-one" };
|
|
|
|
|
db.prepare("INSERT INTO environment_operations(operation_id,binding_id,kind,request_json,state,receipt_json) VALUES ('launch-generation-one','binding-one','launch',?,'confirmed',?)").run(JSON.stringify(receipt), JSON.stringify(receipt));
|
|
|
|
|
}
|
|
|
|
|
const effect = (name: string, operationId = "effect-one") => request("/binding-one/effect", { operationId, generation: "generation-one", effect: name });
|
|
|
|
|
const lifecycle = new EnvironmentLifecycle(new EnvironmentPlatform(db, driver), queue, join(root, "retained"));
|
|
|
|
|
return { root, db, driver, queue, lifecycle, request, effect, resource, native, files, history, capture: () => f.snapshotCapture.captureSnapshot(rig.id, "manual"), disappear: () => { present = false; }, counts: () => ({ stopCalls, deleteCalls, creates }), loseStop: () => { loseStop = true; }, unavailable: () => { unavailable = true; }, dirtyCleanup: () => { dependents.volumes = true; } };
|
|
|
|
|
}
|
|
|
|
|
describe("owned and attached environment effects", () => {
|
|
|
|
|
async function failedProjection(attached = false) {
|
|
|
|
|
const f = await fixture(attached, true, true), platform = new EnvironmentPlatform(f.db, f.driver);
|
|
|
|
|
vi.spyOn(f.driver, "read").mockRejectedValueOnce(new Error("guest root read denied"));
|
|
|
|
|
const source = join(f.root, "policy.yaml");
|
|
|
|
|
await expect(platform.project("binding-one", { operationId: "projection-failed", generation: "not-established", files: [
|
|
|
|
|
{ path: "work/role.md", source, sha256: sha256(readFileSync(source)), required: true },
|
|
|
|
|
] })).rejects.toThrow("guest root read denied");
|
|
|
|
|
expect(platform.get("binding-one")).toMatchObject({ generation: null, resource: { id: f.resource.id } });
|
|
|
|
|
expect(platform.get("binding-one")!.nodeId).toBeUndefined();
|
|
|
|
|
return { ...f, cleanup: () => f.request("/binding-one/effect", { operationId: "cleanup-failed-create", effect: "delete-owned", generation: null, resourceId: f.resource.id }) };
|
|
|
|
|
}
|
|
|
|
|
it("cleans the exact owned create after projection fails before node and generation binding", async () => {
|
|
|
|
|
const f = await failedProjection(), result = await f.cleanup();
|
|
|
|
|
expect(result.status, JSON.stringify(result.body)).toBe(200);
|
|
|
|
|
expect(result.body).toMatchObject({ state: "confirmed", generation: null, resourceId: "compute-one", effect: "delete-owned" });
|
|
|
|
|
expect(f.counts()).toMatchObject({ creates: 1, deleteCalls: 1, stopCalls: 0 });
|
|
|
|
|
expect((await f.cleanup()).body).toEqual(result.body); expect(f.counts().deleteCalls).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
it("reconciles uncertain pre-generation cleanup without another provider delete", async () => {
|
|
|
|
|
const f = await failedProjection();
|
|
|
|
|
let observe = false;
|
|
|
|
|
vi.spyOn(f.driver, "deleteEnvironment").mockImplementationOnce(async () => { f.disappear(); throw new Error("lost delete reply"); });
|
|
|
|
|
const original = f.driver.cleanupState;
|
|
|
|
|
vi.spyOn(f.driver, "cleanupState").mockImplementation(async () => { if (!observe) throw new Error("engine unavailable"); return original(); });
|
|
|
|
|
expect((await f.cleanup()).body.state).toBe("unconfirmed");
|
|
|
|
|
observe = true;
|
|
|
|
|
expect((await f.cleanup()).body.state).toBe("confirmed");
|
|
|
|
|
expect(f.driver.deleteEnvironment).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
it("preserves an attached resource after projection fails before a generation exists", async () => {
|
|
|
|
|
const f = await failedProjection(true), result = await f.cleanup();
|
|
|
|
|
expect(result.status).toBe(409); expect(result.body.code).toBe("environment_attached_preserved");
|
|
|
|
|
expect(f.counts()).toMatchObject({ creates: 0, deleteCalls: 0, stopCalls: 0 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("stops only the selected native and reconciles a lost acknowledgement without a second signal", async () => {
|
|
|
|
|
const f = await fixture(); f.loseStop();
|
|
|
|
|
const first = await f.effect("stop-native"); expect(first.body.state).toBe("confirmed");
|
|
|
|
|