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
2 changes: 1 addition & 1 deletion docs/src/content/docs/using/carve.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ behold writes only into the copy it just made, and only into `app/carveout/` ins

### The `--live` tier

`npx behold demo carve --live` (docker + terraform on PATH) boots a scratch Floci — its own container name and port, refused if taken, deleted on exit — arms the estate's provider override into the demo copy, and really applies the starred resources into it. The advisor then reads a tfstate terraform wrote, and the Handoff step gains a read-only **terraform plan** button: run it after pasting `terraform state rm` and the carved resource is simply absent from the plan — `0 to destroy`, nothing blinked. The one beat still deferred is chant reading the bucket live while Terraform owns it (chant#1647: AWS live observe is CFN-stack-scoped today).
`npx behold demo carve --live` (docker + terraform on PATH) boots a scratch Floci — its own container name and port, refused if taken, deleted on exit — arms the estate's provider override into the demo copy, and really applies the starred resources into it. The advisor then reads a tfstate terraform wrote, and the Handoff step gains a read-only **terraform plan** button: run it after pasting `terraform state rm` and the carved resource is simply absent from the plan — `0 to destroy`, nothing blinked. The Emit step gains the observe beat: **watch chant read it live** runs `chant lifecycle diff --live` in the carveout against the scratch Floci, and the carved resource reads observed — `EXTERNAL`, ownership `foreign` — while Terraform still owns it (chant#1647's identity read path, chant ≥ 0.44.12).

## For agents

Expand Down
12 changes: 7 additions & 5 deletions example-carve/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,13 @@ it the carved bucket is simply gone from Terraform's world — `0 to destroy`.
The line the tier exists for is "Terraform forgot it, chant adopted it, and it
never blinked."

One beat stays caption rather than footage for now: `chant lifecycle diff
--live` reading the bucket clean while Terraform still owns it. chant's AWS
observe is CFN-stack-scoped by logical id, so a Terraform-owned resource reads
confirmed-missing regardless of its existence — chant#1647 tracks the
physical-identity read path that unlocks it.
The observe beat is footage now, not caption: after Emit, "▶ watch chant read
it live" runs `chant lifecycle diff prod --live` in the carveout with the
endpoint pointed at the scratch Floci, and the bucket reads OBSERVED —
`EXTERNAL`, ownership `foreign` — while Terraform still owns it. That is
chant#1647's physical-identity read path (chant ≥ 0.44.12): stack-absent no
longer means confirmed-absent when the declared `BucketName` names the
resource precisely.

The live tier needs `docker` and `terraform` on PATH, boots its own throwaway
Floci and deletes it after, and never touches an existing `floci*` container.
Expand Down
18 changes: 9 additions & 9 deletions example-carve/app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions example-carve/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"build": "./node_modules/.bin/chant build src --lexicon aws -o template.json"
},
"dependencies": {
"@intentius/chant": "^0.44.7",
"@intentius/chant-lexicon-aws": "^0.44.7"
"@intentius/chant": "^0.44.12",
"@intentius/chant-lexicon-aws": "^0.44.12"
}
}
78 changes: 77 additions & 1 deletion src/carve-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
readArtifacts,
runCarveBridge,
runCarveEmit,
runCarveObserve,
selectFromReport,
shortenIn,
type CarveDemo,
Expand Down Expand Up @@ -64,7 +65,7 @@ function fakeDemo(): { demo: CarveDemo; argv: () => string[][]; root: string } {
writeFileSync(
bin,
`#!/usr/bin/env node
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
const argv = process.argv.slice(2);
appendFileSync(${JSON.stringify(join(root, "argv.log"))}, JSON.stringify(argv) + "\\n");
Expand Down Expand Up @@ -99,6 +100,13 @@ if (argv[0] === "lint") {
console.log("\\u26a0 1 warnings");
process.exit(0);
}
if (argv[0] === "lifecycle" && argv[1] === "diff") {
// The observe beat's read (#254, chant#1647): record the endpoint the spawn
// was handed, then answer with whatever the test canned.
writeFileSync(${JSON.stringify(join(root, "env.log"))}, process.env.AWS_ENDPOINT_URL || "");
console.log(readFileSync(${JSON.stringify(join(root, "diff-answer.json"))}, "utf8"));
process.exit(0);
}
console.error("fake chant: unexpected " + argv.join(" "));
process.exit(2);
`,
Expand Down Expand Up @@ -227,6 +235,74 @@ describe("runCarveEmit — against a fake project-local chant", () => {
});
});

describe("runCarveObserve — the observe beat (#254, chant#1647)", () => {
let d: ReturnType<typeof fakeDemo>;
const live = { container: "behold-carve-floci", port: 4602, endpoint: "http://localhost:4602", applied: [] };
beforeAll(() => {
d = fakeDemo();
});
afterAll(() => rmSync(d.root, { recursive: true, force: true }));

it("refuses on a non-live boot — there is no live to read", async () => {
const r = await runCarveObserve(d.demo, "aws_s3_bucket.assets");
expect("ok" in r && r.ok).toBe(false);
if ("ok" in r && !r.ok) expect(r.refusal.remedy).toContain("--live");
});

it("refuses before Emit — observe reads the carveout emit writes", async () => {
const r = await runCarveObserve({ ...d.demo, live }, "aws_s3_bucket.assets");
expect("ok" in r && r.ok).toBe(false);
if ("ok" in r && !r.ok) expect(r.refusal.remedy).toContain("Emit");
});

it("reads the carveout with the endpoint pointed at the scratch Floci, and reports the observed verdict", async () => {
await runCarveEmit(d.demo, "aws_s3_bucket.assets");
writeFileSync(
join(d.root, "diff-answer.json"),
JSON.stringify({
environment: "prod",
lexicons: {
aws: {
resources: { missing: [], orphan: [], unchanged: [], unobserved: [], queried: { assets: "cloudcontrol:GetResource:AWS::S3::Bucket:acme-platform-assets-prod" } },
observed: { assets: { type: "AWS::S3::Bucket", physicalId: "acme-platform-assets-prod", status: "EXTERNAL", ownership: "foreign" } },
},
},
}),
);
const r = await runCarveObserve({ ...d.demo, live }, "aws_s3_bucket.assets");
expect("ok" in r && r.ok).toBe(true);
if (!("ok" in r) || !r.ok) return;
expect(r).toMatchObject({
entity: "assets",
verdict: "observed",
status: "EXTERNAL",
ownership: "foreign",
physicalId: "acme-platform-assets-prod",
queried: "cloudcontrol:GetResource:AWS::S3::Bucket:acme-platform-assets-prod",
});
// The argv is the diff, the spawn got the scratch endpoint, and the echoed
// command is retypeable without the host's tmpdir in it.
const diffArgv = d.argv().find((a) => a[0] === "lifecycle");
expect(diffArgv).toEqual(["lifecycle", "diff", "prod", "--live", "--json"]);
expect(readFileSync(join(d.root, "env.log"), "utf8")).toBe(live.endpoint);
expect(r.command).toContain(live.endpoint);
expect(r.command).toContain("app/carveout");
expect(r.command).not.toContain(d.root);
});

it("a miss stays a miss — the verdict is chant's, never invented here", async () => {
writeFileSync(
join(d.root, "diff-answer.json"),
JSON.stringify({ environment: "prod", lexicons: { aws: { resources: { missing: ["assets"], queried: { assets: "cloudcontrol:GetResource:AWS::S3::Bucket:acme-platform-assets-prod" } }, observed: {} } } }),
);
const r = await runCarveObserve({ ...d.demo, live }, "aws_s3_bucket.assets");
expect("ok" in r && r.ok).toBe(true);
if (!("ok" in r) || !r.ok) return;
expect(r.verdict).toBe("missing");
expect(r.queried).toContain("AWS::S3::Bucket");
});
});

describe("runCarveBridge — against a fake project-local chant", () => {
it("never passes --apply-rewrites, and splits the runbook out from the proposals", async () => {
const d = fakeDemo();
Expand Down
97 changes: 97 additions & 0 deletions src/carve-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,103 @@ export async function runCarvePlan(demo: CarveDemo | undefined): Promise<CarveAc
};
}

export interface CarveObserveResult {
ok: true;
select: string;
/** The retypeable command line, endpoint included. */
command: string;
/** The carved entity's chant name (the tf address's name part). */
entity: string;
verdict: "observed" | "missing" | "unobserved";
/** Present on `observed`. `EXTERNAL` + `foreign` is the whole point: live,
* outside every stack, owned by something that isn't chant — Terraform. */
status?: string;
ownership?: string;
physicalId?: string;
/** The address the read was issued against (chant#1620) — present whenever
* chant recorded one, whatever the verdict. */
queried?: string;
/** The unobserved reason/detail, when the read itself had a hole. */
detail?: string;
}

/**
* The observe beat (#254, chant#1647→#1684/#1695): after Emit, chant reads the
* carved resource live FROM THE CARVEOUT — while Terraform still owns it.
* That is the walkthrough's claim ("Terraform forgot it, chant adopted it, and
* it never blinked") made checkable: `lifecycle diff prod --live --json` runs
* in `<demo.out>` (emit writes a full mini-project there), with
* `AWS_ENDPOINT_URL` pointed at the scratch Floci for this one spawn. Live
* tier only, read-only — a diff observes, it never applies.
*
* `prod` is the demo estate's one environment (example-carve/app's config);
* the carveout inherits the walkthrough's shape, not a general project's.
*/
export async function runCarveObserve(demo: CarveDemo, select: string): Promise<CarveActionResult<CarveObserveResult>> {
if (!demo.live) {
return refuse(
"carve-action",
"the observe beat reads the scratch Floci, and this server isn't serving one",
"run `behold demo carve --live` (needs docker + terraform)",
);
}
if (!existsSync(join(demo.out, "src"))) {
return refuse("carve-action", "nothing has been emitted yet", "run Emit first — observe reads the carveout emit writes");
}
const args = ["lifecycle", "diff", "prod", "--live", "--json"];
const run = await runChantRaw(args, demo.out, { AWS_ENDPOINT_URL: demo.live.endpoint }).catch((err: unknown) => ({
code: 127,
stdout: "",
stderr: err instanceof Error ? err.message : String(err),
}));
if (run.code !== 0) {
return refuse(
"carve-action",
`chant lifecycle diff exited ${run.code}: ${merge(run, demo.root) || "(no output)"}`,
"Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".",
);
}
let aws: {
resources?: { missing?: string[]; unobserved?: Array<{ name: string; reason?: string; detail?: string }>; queried?: Record<string, string> };
observed?: Record<string, { status?: string; ownership?: string; physicalId?: string }>;
};
try {
aws = (JSON.parse(run.stdout) as { lexicons?: Record<string, typeof aws> }).lexicons?.aws ?? {};
} catch {
return refuse("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run — a partial read is not a verdict");
}
const entity = select.split(".").pop() ?? select;
const command = `AWS_ENDPOINT_URL=${demo.live.endpoint} chant ${args.join(" ")} # in ${shortenIn(demo.out, demo.root)}`;
const queried = aws.resources?.queried?.[entity];
const meta = aws.observed?.[entity];
if (meta) {
return {
ok: true,
select,
command,
entity,
verdict: "observed",
...(meta.status ? { status: meta.status } : {}),
...(meta.ownership ? { ownership: meta.ownership } : {}),
...(meta.physicalId ? { physicalId: meta.physicalId } : {}),
...(queried ? { queried } : {}),
};
}
const hole = aws.resources?.unobserved?.find((u) => u.name === entity);
if (hole) {
return {
ok: true,
select,
command,
entity,
verdict: "unobserved",
...(hole.detail || hole.reason ? { detail: [hole.reason, hole.detail].filter(Boolean).join(": ") } : {}),
...(queried ? { queried } : {}),
};
}
return { ok: true, select, command, entity, verdict: "missing", ...(queried ? { queried } : {}) };
}

/** Caps on what comes back through the wire. A carve emits one source file and
* a handful of proposals; anything past these is a mistake, and the answer is a
* truncated read, never an unbounded one. */
Expand Down
9 changes: 4 additions & 5 deletions src/carve-live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@
* The offline tier proves the mechanics against a synthetic tfstate; this tier
* turns "the resource stays live through the carve" from a caption into
* something the viewer can poke: a scratch Floci in Docker, the demo copy's
* Terraform REALLY applied into it, and a real `terraform plan` at handoff
* showing no destroy. The observe beat — chant reading the bucket live while
* Terraform still owns it — stays deferred on chant#1647 (AWS live observe is
* CFN-stack-scoped by logical id; a Terraform-owned resource reads
* confirmed-missing today).
* Terraform REALLY applied into it, a real `terraform plan` at handoff showing
* no destroy, and the observe beat after Emit — chant reading the carved
* resource live from the carveout while Terraform still owns it (chant#1647's
* identity read path, chant ≥ 0.44.12; runCarveObserve in carve-actions.ts).
*
* Scratch discipline (HANDOFF standing constraint): our own container name,
* refuse-if-exists, teardown on exit — never an existing `floci*` or
Expand Down
4 changes: 3 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,9 @@ function spawnStep(cmd: string, args: string[], cwd: string): Promise<number> {
* applied into it, so the tfstate the advisor reads was written by terraform.
* Live fails fast rather than degrading — a "live" walkthrough silently
* serving synthetic state would be the demo lying about its one claim. The
* observe beat stays deferred on chant#1647.
* observe beat rides it (chant#1647, fixed at chant ≥ 0.44.12): after Emit,
* chant reads the carved resource live from the carveout while Terraform
* still owns it.
*/
async function serveCarveDemo(target: string, carve: DemoCarve, port: number, live = false): Promise<void> {
const at = (rel: string): string => resolve(target, rel);
Expand Down
6 changes: 6 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
carveWriteBlock,
runCarveBridge,
runCarveEmit,
runCarveObserve,
runCarvePlan,
selectFromReport,
BUILD_CAVEAT,
Expand Down Expand Up @@ -606,6 +607,11 @@ function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void {

app.post("/api/carve/emit", (c) => runStep(c, runCarveEmit));
app.post("/api/carve/bridge", (c) => runStep(c, runCarveBridge));
// The observe beat (#254, chant#1647): chant reads the carved resource live
// from the carveout, endpoint pointed at the scratch Floci. Same select
// discipline as emit/bridge (membership in the report), refuses on a
// non-live boot or before Emit (runCarveObserve). Read-only.
app.post("/api/carve/observe", (c) => runStep(c, runCarveObserve));

// The live tier's third action (#254): `terraform plan`, read-only against
// both the estate and the emulator. Same guard posture as emit/bridge (JSON
Expand Down
30 changes: 30 additions & 0 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,35 @@ const carveActions = {
},
runEmit: () => runCarveStep("emit"),
runBridge: () => runCarveStep("bridge"),
// The observe beat (#254, chant#1647). Not runCarveStep: the refusal has to
// land on the Emit step (where the button lives), and "observe" is a live-
// tier action on that step, not a step of its own.
async runObserve() {
if (carveState.busy || !carveState.pick) return;
carveState.busy = "observe";
carveState.error = null;
renderPanelCarve();
try {
const res = await fetch("/api/carve/observe", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ select: carveState.pick.node.id }),
});
const body = await res.json().catch(() => ({ error: "observe returned an unreadable body", remedy: "" }));
if (!res.ok || body.error) {
carveState.error = { step: "emit", ...body };
showToast(`✗ observe: ${body.error || res.status}`, false);
} else {
carveState.observe = body;
showToast(body.verdict === "observed" ? "✓ chant read it live — nothing blinked" : `✗ observe: ${body.verdict}`, body.verdict === "observed");
}
} catch (err) {
carveState.error = { step: "emit", error: String((err && err.message) || err), remedy: "Is the behold server still running?" };
} finally {
carveState.busy = null;
renderPanelCarve();
}
},
async runPlan() {
if (carveState.busy) return;
carveState.busy = "plan";
Expand Down Expand Up @@ -1321,6 +1350,7 @@ function carvePick(node) {
carveState.emit = null;
carveState.bridge = null;
carveState.handoff = false;
carveState.observe = null;
carveState.error = null;
carveState.step = pickStep;
} else if (carveState.step < pickStep) {
Expand Down
Loading
Loading