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
4 changes: 3 additions & 1 deletion docs/src/content/docs/using/carve.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ The Emit step reports **`chant lint`, not `chant build`**. chant#1637: `carve em

behold writes only into the copy it just made, and only into `app/carveout/` inside it. Both steps refuse outside a demo copy; a plain `behold carve report.json` shows the same six steps with the runs greyed out and a reason.

Deferred to a follow-up: the Floci `--live` tier (`terraform apply` into a scratch emulator, live observe beats, a real `terraform plan` showing no destroy) and the morph animation that slides the carved card out of the Terraform boundary and into the chant project box the estate frame now draws.
### 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).

## For agents

Expand Down
23 changes: 15 additions & 8 deletions example-carve/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,14 +170,21 @@ no AWS account and no network. The only dependency beyond this directory is
`@cdktf/hcl2json`, which chant lazy-loads and names in its error if absent. This
tier guarantees the first thirty seconds of the video.

**`--live` is the full video.** Boot a scratch Floci, arm
`legacy-tf/floci-override.tf.disabled` (see its header), and `terraform apply`
the estate into it, so the state is one terraform really wrote. Then the observe
beats become footage rather than caption: after Emit, `chant lifecycle diff
--live` reads the bucket out of Floci, clean, while Terraform still owns it; the
handoff runs a real `terraform plan` showing no destroy; and behold's overlay
flips the bucket green afterwards. The line it exists for is "Terraform forgot
it, chant adopted it, and it never blinked."
**`--live` is the full video.** `behold demo carve --live` boots a scratch
Floci (`behold-carve-floci`, its own port, deleted on exit), arms
`legacy-tf/floci-override.tf.disabled` (see its header) into the demo copy, and
`terraform apply -target`s the starred resources into it — so the tfstate the
advisor reads is one terraform really wrote. At Handoff the stepper gains a
read-only **terraform plan** button: before `state rm` it shows a no-op, after
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 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
41 changes: 41 additions & 0 deletions src/carve-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { runChantRaw, stripAnsi } from "./chant.ts";
import { unwritableReason } from "./layout.ts";
import type { CarveReport } from "./carve-lens.ts";
import { runLivePlan, type CarveLiveInfo } from "./carve-live.ts";

/**
* A booted `behold demo carve` copy — the only context in which the carve
Expand All @@ -55,11 +56,51 @@ export interface CarveDemo {
project: string;
/** Where emitted source, proposals and the runbook land (`app/carveout/`). */
out: string;
/** The Floci `--live` tier (src/carve-live.ts): present iff this boot really
* applied the estate into a scratch emulator — the tfstate is terraform's
* own, and the plan action below is armed. */
live?: CarveLiveInfo;
/** Set when the boot's own `carve advise` failed and the committed report is
* being served instead — surfaced in the UI rather than swallowed. */
degraded?: string;
}

export interface CarvePlanResult {
ok: true;
command: string;
/** Terraform's own verdict line (`Plan: …` / `No changes.`). */
planLine: string;
/** `-detailed-exitcode`: false = a clean no-op plan. */
changes: boolean;
/** The tier's one claim: nothing gets destroyed. */
noDestroy: boolean;
}

/** The handoff beat's `terraform plan`, live tier only (#254): read-only
* against both the estate and the emulator — before the operator pastes
* `terraform state rm` it shows a no-op, after it it shows the carved
* resource simply absent from Terraform's world, not destroyed. */
export async function runCarvePlan(demo: CarveDemo | undefined): Promise<CarveActionResult<CarvePlanResult>> {
if (!demo?.live) {
return refuse(
"carve-action",
"terraform plan is the live tier's beat, and this server isn't serving one",
"run `behold demo carve --live` (needs docker + terraform)",
);
}
const r = await runLivePlan(demo.from);
if ("error" in r) {
return refuse("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
}
return {
ok: true,
command: "terraform plan -detailed-exitcode (targeted, in the demo copy)",
planLine: r.planLine,
changes: r.changes,
noDestroy: r.noDestroy,
};
}

/** 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
54 changes: 54 additions & 0 deletions src/carve-live.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { armOverride, parsePlanOutput, LIVE_CONTAINER, LIVE_PORT, LIVE_TARGETS } from "./carve-live.ts";

const LEGACY_TF = join(import.meta.dirname, "..", "example-carve", "legacy-tf");

describe("the Floci --live tier (#254)", () => {
it("arms the committed override template completely — no endpoint left on the shared :4566", () => {
const template = readFileSync(join(LEGACY_TF, "floci-override.tf.disabled"), "utf8");
const before = (template.match(/localhost:4566/g) ?? []).length;
expect(before).toBeGreaterThan(0);
const armed = armOverride(template, LIVE_PORT);
expect(armed).not.toContain("localhost:4566");
expect((armed.match(new RegExp(`localhost:${LIVE_PORT}`, "g")) ?? []).length).toBe(before);
// The provider block itself is untouched — only endpoints move.
expect(armed).toContain('provider "aws"');
expect(armed).toContain("s3_use_path_style");
});

it("targets addresses the demo estate actually declares", () => {
const tf = readdirSync(LEGACY_TF)
.filter((f) => f.endsWith(".tf"))
.map((f) => readFileSync(join(LEGACY_TF, f), "utf8"))
.join("\n");
for (const target of LIVE_TARGETS) {
const [type, name] = target.split(".");
expect(tf, `${target} must exist in legacy-tf`).toContain(`resource "${type}" "${name}"`);
}
});

it("keeps scratch discipline — never the shared names, never the shared port", () => {
expect(LIVE_CONTAINER).not.toMatch(/^(floci|chant-floci)$/);
expect(LIVE_PORT).not.toBe(4566);
});

it("reads terraform's verdict line and the no-destroy claim", () => {
const noop = parsePlanOutput("No changes. Your infrastructure matches the configuration.\n", 0);
expect(noop.changes).toBe(false);
expect(noop.noDestroy).toBe(true);
expect(noop.planLine).toContain("No changes.");

const clean = parsePlanOutput("…\nPlan: 0 to add, 0 to change, 0 to destroy.\n", 2);
expect(clean.changes).toBe(true);
expect(clean.noDestroy).toBe(true);
expect(clean.planLine).toBe("Plan: 0 to add, 0 to change, 0 to destroy.");

const destroy = parsePlanOutput("Plan: 0 to add, 0 to change, 1 to destroy.\n", 2);
expect(destroy.noDestroy).toBe(false);

const silent = parsePlanOutput("", 1);
expect(silent.planLine).toContain("exited 1");
});
});
162 changes: 162 additions & 0 deletions src/carve-live.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* The Floci `--live` tier of the carve walkthrough (#254, second comment).
*
* 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).
*
* Scratch discipline (HANDOFF standing constraint): our own container name,
* refuse-if-exists, teardown on exit — never an existing `floci*` or
* `chant-floci*`, never their :4566. The port moves too, so a shared Floci on
* the conventional port is never spoken to by accident: the committed
* override template names :4566 and `armOverride` rewrites it.
*/
import { spawn } from "node:child_process";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";

export interface CarveLiveInfo {
container: string;
port: number;
endpoint: string;
/** The addresses `terraform apply -target` created in the scratch Floci. */
applied: string[];
}

export const LIVE_CONTAINER = "behold-carve-floci";
export const LIVE_PORT = 4602;
/** chant's own pinned emulator image (lexicons/aws floci activity) — pinned,
* not `:latest`, for the same reason chant pins it. */
export const LIVE_IMAGE = "floci/floci:1.5.34";

/** The resources the live tier applies — the starred pair plus the bucket's
* two fold-in sub-resources. Deliberately NOT the whole estate: Floci's
* community edition emulates S3 and CloudWatch Logs; the VPC, lambda and CDN
* are the grey band's scenery and stay paper (the override template says the
* same). `-target` pulls dependencies in on its own. */
export const LIVE_TARGETS = [
"aws_s3_bucket.assets",
"aws_s3_bucket_versioning.assets",
"aws_s3_bucket_public_access_block.assets",
"aws_cloudwatch_log_group.worker",
];

/** Arm the committed override template for a scratch port: every
* `localhost:4566` endpoint becomes `localhost:<port>`. Pure — the caller
* writes the result as `floci_override.tf` (terraform's `*_override.tf` merge
* replaces the provider block without editing `versions.tf`). */
export function armOverride(disabledText: string, port: number): string {
return disabledText.replaceAll("localhost:4566", `localhost:${port}`);
}

/** The one line of a `terraform plan -no-color` that states the verdict, and
* the claim the whole tier exists to film: nothing gets destroyed. Exit code
* (with `-detailed-exitcode`): 0 = no changes, 2 = changes present. */
export function parsePlanOutput(stdout: string, exitCode: number): { planLine: string; changes: boolean; noDestroy: boolean } {
const planLine =
stdout
.split("\n")
.reverse()
.find((l) => l.startsWith("Plan:") || l.includes("No changes.")) ?? `terraform plan exited ${exitCode}`;
const destroyMatch = planLine.match(/(\d+) to destroy/);
return {
planLine: planLine.trim(),
changes: exitCode === 2,
noDestroy: !destroyMatch || destroyMatch[1] === "0",
};
}

type Step = (cmd: string, args: string[], cwd: string) => Promise<number>;

/** A child's stdout+stderr as one string (interleaved, the way a terminal
* shows it), -1 on spawn error. */
function capture(cmd: string, args: string[], cwd: string): Promise<{ code: number; stdout: string }> {
return new Promise((res) => {
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], cwd, shell: process.platform === "win32" });
let out = "";
child.stdout?.on("data", (d: Buffer) => (out += d.toString()));
child.stderr?.on("data", (d: Buffer) => (out += d.toString()));
child.on("error", () => res({ code: -1, stdout: out }));
child.on("close", (code) => res({ code: code ?? 1, stdout: out }));
});
}

/** Boot the scratch Floci: refuse if the name is taken (this tier only ever
* deletes a container it created), run detached with `--rm`, wait for the
* health endpoint to list cloudformation. Returns an error string instead of
* throwing — the caller turns it into a refusal with a remedy. */
export async function bootScratchFloci(): Promise<string | undefined> {
const ps = await capture("docker", ["ps", "-a", "--format", "{{.Names}}"], process.cwd());
if (ps.code !== 0) return "docker isn't answering — is the daemon up?";
if (ps.stdout.split("\n").includes(LIVE_CONTAINER)) {
return `container ${LIVE_CONTAINER} already exists — a previous run didn't tear down. \`docker rm -f ${LIVE_CONTAINER}\` and re-run.`;
}
const run = await capture(
"docker",
["run", "-d", "--rm", "-p", `${LIVE_PORT}:4566`, "--name", LIVE_CONTAINER, LIVE_IMAGE],
process.cwd(),
);
if (run.code !== 0) return `docker run ${LIVE_IMAGE} failed (${run.code})`;
for (let i = 0; i < 30; i++) {
try {
const health = await fetch(`http://localhost:${LIVE_PORT}/_localstack/health`);
if (health.ok && (await health.text()).includes("cloudformation")) return undefined;
} catch {
/* not up yet */
}
await new Promise((r) => setTimeout(r, 1000));
}
await teardownScratchFloci();
return `Floci never reported healthy on :${LIVE_PORT} after 30s`;
}

/** Best-effort removal of OUR container only. Safe to call twice. */
export async function teardownScratchFloci(): Promise<void> {
await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => undefined);
}

/**
* The live boot: arm the override in the copy's Terraform, drop the synthetic
* tfstate, `terraform init` + targeted apply into the scratch Floci. After
* this the tfstate beside the `.tf` files is real — written by terraform —
* and the advisor run that follows reads it. Returns an error string on the
* first failed step; the caller tears the container down and refuses.
*/
export async function applyIntoFloci(fromDir: string, step: Step): Promise<string | undefined> {
const template = join(fromDir, "floci-override.tf.disabled");
if (!existsSync(template)) return `no floci-override.tf.disabled in ${fromDir} — this estate wasn't authored for the live tier`;
writeFileSync(join(fromDir, "floci_override.tf"), armOverride(readFileSync(template, "utf8"), LIVE_PORT));

const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir);
if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`;
const apply = await step(
"terraform",
["apply", "-input=false", "-auto-approve", "-no-color", ...LIVE_TARGETS.map((t) => `-target=${t}`)],
fromDir,
);
if (apply !== 0) return `terraform apply exited ${apply}`;
return undefined;
}

/** `terraform plan` in the demo copy's Terraform, for the handoff beat: after
* `terraform state rm` the plan must show the carved resource is simply gone
* from Terraform's world — not destroyed. Read-only against the estate AND
* the emulator. `-detailed-exitcode` makes the contract explicit: 0 = no-op,
* 2 = changes; anything else is a FAILURE (a dead emulator, a broken config)
* and comes back as `error`, never dressed up as a verdict — a plan that
* couldn't refresh has no standing to say "nothing gets destroyed". */
export async function runLivePlan(
fromDir: string,
): Promise<{ planLine: string; changes: boolean; noDestroy: boolean; exitCode: number } | { error: string; exitCode: number }> {
const r = await capture("terraform", ["plan", "-input=false", "-no-color", "-detailed-exitcode", ...LIVE_TARGETS.map((t) => `-target=${t}`)], fromDir);
if (r.code !== 0 && r.code !== 2) {
const tail = r.stdout.trim().split("\n").slice(-8).join("\n");
return { error: `terraform plan failed (exit ${r.code}):\n${tail}`, exitCode: r.code };
}
return { ...parsePlanOutput(r.stdout, r.code), exitCode: r.code };
}
Loading
Loading