From ecf945e7a714e420269fde7079b28e5eee04e241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Sat, 17 Oct 2026 08:33:54 +0000 Subject: [PATCH] feat(configurator): stream native OpenTofu progress to the UI Deliver redacted runner updates through an authorized bounded NDJSON channel. Preserve snapshot clients, full saved-plan text and exact execution approvals; verify progress before process completion and browser EOF. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- landing-zone-configurator/app/README.md | 22 +- .../app/apps/api/src/plans/local.ts | 27 ++- .../app/apps/api/src/plans/output-stream.ts | 72 +++++++ .../app/apps/api/src/plans/routes.ts | 43 +++- .../app/apps/api/src/plans/service.ts | 14 ++ .../app/apps/web/src/components/PlanRuns.tsx | 38 ++-- .../app/apps/web/src/plan-output.ts | 76 +++++++ .../app/apps/worker/src/runner.ts | 35 +++- .../app/e2e/configurator.spec.ts | 106 ++++++++++ .../app/packages/contracts/src/index.ts | 6 + .../app/packages/contracts/src/plan-output.ts | 53 +++++ .../app/scripts/test-plan-summary.sh | 23 +- .../app/tests/plan-output-stream.test.ts | 196 ++++++++++++++++++ .../app/tests/plan-output.test.ts | 61 ++++++ .../app/tests/platform-plan.test.ts | 16 +- .../app/tests/platform-worker.test.ts | 131 ++++++++---- .../app/tests/runner-progress.test.ts | 47 +++++ .../deploy/runner/run-plan.sh | 3 +- landing-zone-configurator/docs/development.md | 9 + .../docs/plan-execution.md | 31 ++- landing-zone-configurator/docs/user-guide.md | 8 + 21 files changed, 939 insertions(+), 78 deletions(-) create mode 100644 landing-zone-configurator/app/apps/api/src/plans/output-stream.ts create mode 100644 landing-zone-configurator/app/apps/web/src/plan-output.ts create mode 100644 landing-zone-configurator/app/packages/contracts/src/plan-output.ts create mode 100644 landing-zone-configurator/app/tests/plan-output-stream.test.ts create mode 100644 landing-zone-configurator/app/tests/plan-output.test.ts create mode 100644 landing-zone-configurator/app/tests/runner-progress.test.ts diff --git a/landing-zone-configurator/app/README.md b/landing-zone-configurator/app/README.md index c09c317..8719ece 100644 --- a/landing-zone-configurator/app/README.md +++ b/landing-zone-configurator/app/README.md @@ -419,9 +419,15 @@ und verwendet weder Kunden-Credentials noch Kunden-State: LZC_NATIVE_RUNNER_TEST=true LZC_RUNNER_PACKAGE_DIR=../.local/runner-local-20261003-mirror-init npm exec -- vitest run tests/platform-worker.test.ts -t 'real native providers' ``` -Unter **OpenTofu-Ausgabe** zeigt jeder lokale Auftrag die normale CLI-Ausgabe. -Laufende Jobs werden alle zwei Sekunden aktualisiert; **Ausgabe folgen** laesst -sich zum Pruefen aelterer Zeilen anhalten. Erfolgreiche Plattform-Plans werden +Unter **OpenTofu-Ausgabe** zeigen Plattformauftraege auch in Cloud Foundry +Live-Fortschritt ueber einen authentifizierten NDJSON-Kanal. Der Worker liest +vollstaendige Zeilen alle 500 ms; nur geaenderter, redigierter Text wird +verschluesselt gesichert und weitergegeben. Der Plan liefert native +OpenTofu-UI-Ereignisse, bevor der fertige Diff verfuegbar ist; rohe Ereignisobjekte +und deren Output-Daten werden nicht an den Browser gegeben. **Ausgabe folgen** laesst sich zum Pruefen aelterer +Zeilen anhalten. Einklappen und Navigation trennen die Verbindung; erneutes +Oeffnen setzt beim aktuellen Mitschnitt fort. JSON-Snapshots bleiben kompatibel. +Erfolgreiche Plattform-Plans werden read-only mittels `tofu show -no-color` aus dem unveraenderten gespeicherten Artefakt dargestellt, einschliesslich Ressourcen und Attributaenderungen. OpenTofu maskiert sensitive Werte wie in der CLI. Die separate Ausgabe-Route @@ -430,15 +436,19 @@ rohes Plan-Binary oder `show -json` werden nicht an den Browser geliefert. Jeder Plan bleibt an sein unveraenderliches Runner-Paket gebunden. Ein Paketwechsel erfordert einen neuen Plan und dessen ausdrueckliche Freigabe. Laufende Apply-Ausgaben werden ebenfalls aktualisiert. Vor dem terminalen Ergebnis -sichert der Worker den redigierten CLI-Text einmalig und verschluesselt beim Broker; +sichert der Worker den letzten redigierten CLI-Text verschluesselt beim Broker; er bleibt nach Cleanup und Reload unter derselben privaten Zugriffskontrolle lesbar. **Apply-Nachweise** zeigen Plan-ID, SHA-256, Zielorganisation, State-Backend des Auftrags und Abschlusszeit. Die bestehende exakte Organisationsbestaetigung und gegebenenfalls destruktive Freigabe bleiben erforderlich; kein automatischer Retry. Ausgaben sind auf 2 MiB begrenzt; eine Kuerzung wird angezeigt. Die Sicherung ist best-effort: bei hartem Prozessabbruch oder fehlgeschlagenem Upload ist ein -vollstaendiger historischer Mitschnitt nicht garantiert. CF-Live-Ausgabetransport -bleibt offen; bereits gesicherter Text benoetigt keinen laufenden nativen Runner. +vollstaendiger historischer Mitschnitt nicht garantiert. Bereits gesicherter +Text benoetigt keinen laufenden nativen Runner. Waehrend des Streams werden +Sitzung, Tenant, Rolle und Eigentuemerschaft erneut geprueft. Verbindungen werden +spaetestens nach 35 Sekunden beendet und bei aktivem Auftrag neu geoeffnet; +Backpressure, Byte-Limit und hoechstens acht Streams pro Eigentuemer/Tenant und +API-Instanz begrenzen die Ressourcen. Details: [Plan-Ausfuehrung](../docs/plan-execution.md). Für neue Plattformen ist **Accelerator-Standard · Management-S3** der Default. Der ausgewählte Service Account führt den freigegebenen Bootstrap-Apply aus; diff --git a/landing-zone-configurator/app/apps/api/src/plans/local.ts b/landing-zone-configurator/app/apps/api/src/plans/local.ts index 61be0d2..ffe8f1a 100644 --- a/landing-zone-configurator/app/apps/api/src/plans/local.ts +++ b/landing-zone-configurator/app/apps/api/src/plans/local.ts @@ -18,6 +18,7 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { applicationRunnerSourceSchema, + openTofuProgress, platformFirewallImage, platformRunnerSourceSchema, } from "@lzc/contracts"; @@ -291,7 +292,14 @@ export class LocalPlanRunner implements PlanRunner { (value) => JSON.parse(value) as Record, () => ({}), ); - for (const phase of ["init", "validate", "plan", "apply", "migration"]) { + for (const phase of [ + "init", + "validate", + "plan-events", + "plan", + "apply", + "migration", + ]) { const file = await open(join(work, `${phase}.log`), "r").catch( () => null, ); @@ -303,6 +311,15 @@ export class LocalPlanRunner implements PlanRunner { ); const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); let chunk = buffer.subarray(0, bytesRead).toString("utf8"); + chunk = chunk.slice(0, chunk.lastIndexOf("\n") + 1); + if (phase === "plan-events") { + const progress = openTofuProgress(chunk); + chunk = progress.text; + if (progress.invalid) + console.error( + JSON.stringify({ event: "runner_progress_invalid" }), + ); + } const values: unknown[] = [credentials]; while (values.length) { const value = values.pop(); @@ -320,11 +337,15 @@ export class LocalPlanRunner implements PlanRunner { /-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)/g, "(sensitive value)", ); + chunk = `\n$ tofu ${phase === "plan-events" ? "plan (progress)" : phase}\n${chunk}`; if (Buffer.byteLength(chunk) > limit) { - chunk = Buffer.from(chunk).subarray(0, limit).toString("utf8"); + chunk = new TextDecoder().decode( + Buffer.from(chunk).subarray(0, limit), + { stream: true }, + ); truncated = true; } - text += `\n$ tofu ${phase}\n${chunk}`; + text += chunk; truncated ||= bytesRead < (await file.stat()).size; } finally { await file.close(); diff --git a/landing-zone-configurator/app/apps/api/src/plans/output-stream.ts b/landing-zone-configurator/app/apps/api/src/plans/output-stream.ts new file mode 100644 index 0000000..07c2d7f --- /dev/null +++ b/landing-zone-configurator/app/apps/api/src/plans/output-stream.ts @@ -0,0 +1,72 @@ +import { once } from "node:events"; +import { PassThrough } from "node:stream"; +import { setTimeout as delay } from "node:timers/promises"; +import type { PlanOutput } from "@lzc/contracts"; +import type { FastifyReply } from "fastify"; +import { CredentialError } from "../credentials/profiles.js"; + +export function sendPlanOutput( + reply: FastifyReply, + initial: PlanOutput, + read: () => Promise, + wait: (signal: AbortSignal) => Promise = (signal) => + delay(1000, undefined, { signal }), +) { + const controller = new AbortController(); + const output = new PassThrough({ highWaterMark: 64 * 1024 }); + const close = () => controller.abort(); + reply.raw.once("close", close); + reply + .type("application/x-ndjson") + .header("Cache-Control", "private, no-store") + .header("X-Accel-Buffering", "no") + .send(output); + const write = async (event: unknown) => { + if (controller.signal.aborted) return; + if (!output.write(`${JSON.stringify(event)}\n`)) + await once(output, "drain", { signal: controller.signal }); + }; + void (async () => { + const deadline = Date.now() + 30000; + const lifetime = setTimeout(() => controller.abort(), 35000); + try { + let current = initial; + let previous = ""; + while (!controller.signal.aborted) { + const serialized = JSON.stringify(current); + if (serialized !== previous) { + await write({ type: "output", data: current }); + previous = serialized; + } else await write({ type: "heartbeat" }); + if (current.kind !== "live" || Date.now() >= deadline) break; + await wait(controller.signal); + if (controller.signal.aborted) break; + current = await read(); + } + await write({ type: "complete" }); + } catch (error) { + if (!controller.signal.aborted) { + reply.log.warn( + { event: "plan_output_stream_failed" }, + "Plan output stream failed", + ); + output.write( + `${JSON.stringify({ + type: "error", + error: + error instanceof CredentialError + ? error.code + : "plan_output_unavailable", + })}\n`, + ); + } + } finally { + clearTimeout(lifetime); + reply.raw.removeListener("close", close); + const aborted = controller.signal.aborted; + controller.abort(); + if (aborted) output.destroy(); + else output.end(); + } + })(); +} diff --git a/landing-zone-configurator/app/apps/api/src/plans/routes.ts b/landing-zone-configurator/app/apps/api/src/plans/routes.ts index 46eab20..39941d5 100644 --- a/landing-zone-configurator/app/apps/api/src/plans/routes.ts +++ b/landing-zone-configurator/app/apps/api/src/plans/routes.ts @@ -4,10 +4,12 @@ import { z } from "zod"; import { type AuthServices, authenticatedSession, + cookie, validMutation, } from "../auth/routes.js"; import { CredentialError } from "../credentials/profiles.js"; import { RepositoryError } from "../github/repositories.js"; +import { sendPlanOutput } from "./output-stream.js"; import type { Plans } from "./service.js"; export type PlanRoutesServices = Pick< Plans, @@ -21,6 +23,7 @@ export type PlanRoutesServices = Pick< | "state" | "outputs" | "output" + | "waitForOutput" | "recordOutput" | "migration" | "recovery" @@ -34,6 +37,7 @@ export function registerPlans( auth: AuthServices, plans: PlanRoutesServices, ) { + const outputStreams = new Map(); app.register(async (routes) => { routes.setErrorHandler((error, _request, reply) => { if ((error as { statusCode?: number }).statusCode === 413) @@ -78,7 +82,44 @@ export function registerPlans( if (!session) return reply.code(401).send(); const { id } = z.object({ id: z.uuid() }).parse(request.params); reply.header("Cache-Control", "private, no-store"); - return plans.output!(session, id); + const initial = await plans.output!(session, id); + if (!request.headers.accept?.includes("application/x-ndjson")) + return initial; + const owner = `${session.tenantId}:${session.userId}`; + const count = outputStreams.get(owner) ?? 0; + if (count >= 8) + return reply + .code(429) + .header("Retry-After", "2") + .send({ error: "plan_output_stream_limit" }); + outputStreams.set(owner, count + 1); + reply.raw.once("close", () => { + const remaining = (outputStreams.get(owner) ?? 1) - 1; + if (remaining) outputStreams.set(owner, remaining); + else outputStreams.delete(owner); + }); + sendPlanOutput( + reply, + initial, + async () => { + const token = cookie(request, "__Host-lzc-session"); + const current = token + ? await auth.store.resolveSession(token) + : null; + if ( + !current || + current.id !== session.id || + current.tenantId !== session.tenantId || + current.userId !== session.userId + ) + throw new CredentialError(401, "authentication_required"); + return plans.output!(current, id); + }, + plans.waitForOutput + ? (signal) => plans.waitForOutput!(id, signal) + : undefined, + ); + return reply; }); for (const action of ["checkpoint", "reconcile"] as const) { if (!plans[action]) continue; diff --git a/landing-zone-configurator/app/apps/api/src/plans/service.ts b/landing-zone-configurator/app/apps/api/src/plans/service.ts index bb8dca0..4db96e7 100644 --- a/landing-zone-configurator/app/apps/api/src/plans/service.ts +++ b/landing-zone-configurator/app/apps/api/src/plans/service.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { EventEmitter, once } from "node:events"; import { isIP } from "node:net"; import { planResultSchema, @@ -50,6 +51,18 @@ const lockHash = providerLockHash; const invalid = (code: string, status = 409) => new CredentialError(status, code); export class Plans { + private readonly outputUpdates = new EventEmitter(); + + async waitForOutput(id: string, signal: AbortSignal) { + try { + await once(this.outputUpdates, id, { + signal: AbortSignal.any([signal, AbortSignal.timeout(1000)]), + }); + } catch (error) { + if (!(error instanceof Error && error.name === "AbortError")) throw error; + } + } + constructor( private readonly pool: pg.Pool, private readonly profiles: Pick< @@ -488,6 +501,7 @@ export class Plans { ); if (!updated.rowCount) throw invalid("invalid_runner_transition"); }); + this.outputUpdates.emit(id); } private async preparation(session: Session, id: string) { diff --git a/landing-zone-configurator/app/apps/web/src/components/PlanRuns.tsx b/landing-zone-configurator/app/apps/web/src/components/PlanRuns.tsx index 015041c..91fa340 100644 --- a/landing-zone-configurator/app/apps/web/src/components/PlanRuns.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/PlanRuns.tsx @@ -9,6 +9,7 @@ import { platformContractSchema } from "@lzc/domain"; import { useEffect, useRef, useState } from "react"; import { currentLanguage, t } from "../i18n"; import type { DeploymentStep } from "../navigation"; +import { readPlanOutput } from "../plan-output"; import type { Session } from "./Account"; type Run = { @@ -197,11 +198,15 @@ function PlanOutput({ run }: { run: Run }) { let timer: ReturnType | undefined; const deadline = Date.now() + 20 * 60 * 1000; async function load() { + let reconnectAfter = 2000; setLoading(true); try { const response = await fetch(`/api/v1/plans/${run.id}/output`, { credentials: "same-origin", cache: "no-store", + headers: { + Accept: running ? "application/x-ndjson" : "application/json", + }, signal: controller.signal, }); if (!response.ok) @@ -212,24 +217,23 @@ function PlanOutput({ run }: { run: Run }) { ? "Keine Berechtigung für diese Ausgabe." : "Die OpenTofu-Ausgabe ist momentan nicht verfügbar.", ); - const result = await response.json(); - if ( - typeof result.text !== "string" || - result.text.length > 3 * 1024 * 1024 || - typeof result.truncated !== "boolean" - ) - throw new Error( - "Die OpenTofu-Ausgabe konnte nicht bestätigt werden.", - ); - if (controller.signal.aborted) return; + const kind = await readPlanOutput(response, (result) => { + if (controller.signal.aborted) return; + if ( + result.text || + result.kind === "saved-plan" || + result.kind === "execution" + ) + setText(result.text); + setTruncated(result.truncated); + setLoading(false); + setError(""); + }); if ( - result.text || - result.kind === "saved-plan" || - result.kind === "execution" + kind === "live" && + response.headers.get("content-type")?.includes("application/x-ndjson") ) - setText(result.text); - setTruncated(result.truncated); - setError(""); + reconnectAfter = 100; } catch (cause) { if (!controller.signal.aborted) setError( @@ -239,7 +243,7 @@ function PlanOutput({ run }: { run: Run }) { if (!controller.signal.aborted) { setLoading(false); if (running && Date.now() < deadline) - timer = setTimeout(() => void load(), 2000); + timer = setTimeout(() => void load(), reconnectAfter); } } } diff --git a/landing-zone-configurator/app/apps/web/src/plan-output.ts b/landing-zone-configurator/app/apps/web/src/plan-output.ts new file mode 100644 index 0000000..7f2d08a --- /dev/null +++ b/landing-zone-configurator/app/apps/web/src/plan-output.ts @@ -0,0 +1,76 @@ +import { + type PlanOutput, + planOutputEventSchema, + planOutputSchema, +} from "@lzc/contracts"; + +const invalidOutput = () => + new Error("Die OpenTofu-Ausgabe konnte nicht bestätigt werden."); + +export async function readPlanOutput( + response: Response, + update: (output: PlanOutput) => void, +) { + function accept(output: PlanOutput) { + if (new TextEncoder().encode(output.text).byteLength > 2 * 1024 * 1024) + throw invalidOutput(); + update(output); + } + if (!response.headers.get("content-type")?.includes("application/x-ndjson")) { + const result = planOutputSchema.safeParse(await response.json()); + if (!result.success) throw invalidOutput(); + accept(result.data); + return result.data.kind; + } + if (!response.body) throw invalidOutput(); + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let pending = ""; + let kind: PlanOutput["kind"] | undefined; + let complete = false; + try { + while (!complete) { + const { done, value } = await reader.read(); + if (done) break; + pending += decoder.decode(value, { stream: true }); + if (pending.length > 13 * 1024 * 1024) throw invalidOutput(); + let newline = pending.indexOf("\n"); + while (newline !== -1) { + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + let raw: unknown; + try { + raw = JSON.parse(line); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + throw invalidOutput(); + } + const event = planOutputEventSchema.safeParse(raw); + if (!event.success || complete) throw invalidOutput(); + if (event.data.type === "output") { + accept(event.data.data); + kind = event.data.data.kind; + } else if (event.data.type === "error") + throw new Error( + event.data.error === "authentication_required" + ? "Bitte erneut anmelden." + : event.data.error === "credential_role_required" || + event.data.error === "plan_not_found" + ? "Keine Berechtigung für diese Ausgabe." + : "Die OpenTofu-Ausgabe ist momentan nicht verfügbar.", + ); + else if (event.data.type === "complete") complete = true; + newline = pending.indexOf("\n"); + } + } + if (!complete || !kind || pending || decoder.decode()) + throw invalidOutput(); + return kind; + } finally { + try { + await reader.cancel(); + } finally { + reader.releaseLock(); + } + } +} diff --git a/landing-zone-configurator/app/apps/worker/src/runner.ts b/landing-zone-configurator/app/apps/worker/src/runner.ts index a153262..0b726ac 100644 --- a/landing-zone-configurator/app/apps/worker/src/runner.ts +++ b/landing-zone-configurator/app/apps/worker/src/runner.ts @@ -19,6 +19,7 @@ import { promisify } from "node:util"; import { applicationMvpAcceleratorCommit, applicationRunnerBindingSchema, + openTofuProgress, planResultSchema, platformFirewallImage, platformRunnerSourceSchema, @@ -407,8 +408,9 @@ export async function runWorker( LZC_PROVIDER_MIRROR: resolve(root, "providers"), ...backendEnv, }; + let acknowledgedOutput: { text: string; truncated: boolean } | undefined; + let invalidProgressReported = false; publishOutput = async (live = false) => { - outputReported = false; try { const values: unknown[] = [ key, @@ -430,6 +432,7 @@ export async function runWorker( for (const [fileName, commandName] of [ ["init", "init"], ["validate", "validate"], + ["plan-events", "plan (progress)"], ["plan", "plan"], ["apply", "apply saved-plan.bin"], ["migration", "init -migrate-state"], @@ -453,6 +456,17 @@ export async function runWorker( let content = buffer.subarray(0, bytesRead).toString("utf8"); if (live && bytesRead === size) content = content.slice(0, content.lastIndexOf("\n") + 1); + if (fileName === "plan-events") { + content = content.slice(0, content.lastIndexOf("\n") + 1); + const progress = openTofuProgress(content); + content = progress.text; + if (progress.invalid && !invalidProgressReported) { + console.error( + JSON.stringify({ event: "runner_progress_invalid" }), + ); + invalidProgressReported = true; + } + } let chunk = `$ tofu ${commandName}\n${content}\n`; for (const secret of secrets) chunk = chunk.replaceAll(secret, "(sensitive value)"); @@ -462,15 +476,28 @@ export async function runWorker( ); truncated ||= bytesRead < size || Buffer.byteLength(chunk) > remaining; - text += Buffer.from(chunk).subarray(0, remaining).toString("utf8"); + text += new TextDecoder().decode( + Buffer.from(chunk).subarray(0, remaining), + { stream: true }, + ); } finally { await file.close(); } } diagnostic = { text, truncated }; + if ( + acknowledgedOutput?.text === text && + acknowledgedOutput.truncated === truncated + ) { + outputReported = true; + return; + } + outputReported = false; await report("output", diagnostic); + acknowledgedOutput = diagnostic; outputReported = true; } catch { + outputReported = false; console.error(JSON.stringify({ event: "runner_output_failed" })); } }; @@ -489,10 +516,10 @@ export async function runWorker( let outputStopped = false; const pollOutput = () => { pendingOutput = publishOutput(true).finally(() => { - if (!outputStopped) outputTimer = setTimeout(pollOutput, 2000); + if (!outputStopped) outputTimer = setTimeout(pollOutput, 500); }); }; - if (broker === "platform") outputTimer = setTimeout(pollOutput, 2000); + if (broker === "platform") outputTimer = setTimeout(pollOutput, 500); try { await new Promise((done, fail) => { const child = spawn( diff --git a/landing-zone-configurator/app/e2e/configurator.spec.ts b/landing-zone-configurator/app/e2e/configurator.spec.ts index b8046c0..95427f8 100644 --- a/landing-zone-configurator/app/e2e/configurator.spec.ts +++ b/landing-zone-configurator/app/e2e/configurator.spec.ts @@ -1,4 +1,5 @@ import { readFile } from "node:fs/promises"; +import { createServer, type ServerResponse } from "node:http"; import { assistantProposalFields, type PlatformApplyBlocker, @@ -4863,6 +4864,111 @@ test.describe("explicit saved-plan approval", () => { }); }); + test("renders true live output before EOF and reconnects after collapse without duplicates", async ({ + page, + }) => { + await setup(page); + let connection: ServerResponse | undefined; + let latest = "$ tofu plan (progress)\nRefreshing state...\n"; + let reads = 0; + let disconnects = 0; + let finished = false; + const frame = (kind: "live" | "execution") => + `${JSON.stringify({ + type: "output", + data: { text: latest, truncated: false, kind }, + })}\n`; + const server = createServer((_request, response) => { + reads++; + connection = response; + response.writeHead(200, { + "Content-Type": "application/x-ndjson", + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-store", + }); + response.write(frame(finished ? "execution" : "live")); + if (finished) response.end('{"type":"complete"}\n'); + response.once("close", () => disconnects++); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + try { + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Missing output test server address"); + await page.route("**/api/v1/plans", (route) => + route.fulfill({ + json: { + runs: [ + { + ...savedPlan(), + status: finished ? "succeeded" : "planning", + summary: finished ? savedPlan().summary : null, + applyAllowed: false, + }, + ], + }, + }), + ); + await page.route(`**/api/v1/plans/${planId}/output`, (route) => + route.continue({ + url: `http://127.0.0.1:${address.port}/output`, + }), + ); + await page.goto("/deployments/plan"); + const console = page.locator("textarea.plan-console"); + await expect(console).toHaveValue(latest); + expect(connection?.writableEnded).toBe(false); + expect(finished).toBe(false); + expect(reads).toBe(1); + await page.getByText("OpenTofu-Ausgabe", { exact: true }).click(); + await expect.poll(() => disconnects).toBe(1); + latest += "Refresh complete\n"; + await page.getByText("OpenTofu-Ausgabe", { exact: true }).click(); + await expect(console).toHaveValue(latest); + expect(reads).toBe(2); + connection?.end('{"type":"complete"}\n'); + await expect.poll(() => reads, { timeout: 1500 }).toBe(3); + await expect(console).toHaveValue(latest); + latest += Array.from( + { length: 100 }, + (_, index) => `Planning resource ${index}\n`, + ).join(""); + connection?.write(frame("live")); + await expect(console).toHaveValue(latest); + await expect + .poll(() => + console.evaluate( + (element: HTMLTextAreaElement) => + element.scrollHeight - element.clientHeight - element.scrollTop, + ), + ) + .toBeLessThanOrEqual(2); + await page.getByRole("checkbox", { name: "Ausgabe folgen" }).uncheck(); + latest += "Plan: 1 to add, 0 to change, 0 to destroy.\n"; + connection?.write(frame("execution")); + connection?.end('{"type":"complete"}\n'); + finished = true; + await expect(console).toHaveValue(latest); + expect(reads).toBe(3); + await page + .getByRole("button", { name: "Weiter zu Apply", exact: true }) + .click(); + await expect( + page.getByRole("button", { + name: "Freigegebenen Plan anwenden", + exact: true, + }), + ).toBeDisabled(); + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + }); + test("follows live OpenTofu output and switches to saved plan details when it finishes", async ({ page, }, testInfo) => { diff --git a/landing-zone-configurator/app/packages/contracts/src/index.ts b/landing-zone-configurator/app/packages/contracts/src/index.ts index b085c22..0ce5277 100644 --- a/landing-zone-configurator/app/packages/contracts/src/index.ts +++ b/landing-zone-configurator/app/packages/contracts/src/index.ts @@ -128,6 +128,12 @@ export { platformUpgradeAcceleratorCommit, summarizePlan, } from "./plan.js"; +export type { PlanOutput } from "./plan-output.js"; +export { + openTofuProgress, + planOutputEventSchema, + planOutputSchema, +} from "./plan-output.js"; export const planStageSchema = z.enum([ "initializing", "validating", diff --git a/landing-zone-configurator/app/packages/contracts/src/plan-output.ts b/landing-zone-configurator/app/packages/contracts/src/plan-output.ts new file mode 100644 index 0000000..9effdfc --- /dev/null +++ b/landing-zone-configurator/app/packages/contracts/src/plan-output.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +export const planOutputSchema = z.strictObject({ + text: z.string().max(2 * 1024 * 1024), + truncated: z.boolean(), + kind: z.enum(["live", "execution", "saved-plan"]), +}); +export type PlanOutput = z.infer; +export const planOutputEventSchema = z.discriminatedUnion("type", [ + z.strictObject({ type: z.literal("output"), data: planOutputSchema }), + z.strictObject({ type: z.literal("heartbeat") }), + z.strictObject({ type: z.literal("complete") }), + z.strictObject({ + type: z.literal("error"), + error: z.string().min(1).max(128), + }), +]); + +const nativeEventSchema = z.object({ + type: z.string(), + "@message": z.string(), + ui: z.string().optional(), +}); +export function openTofuProgress(content: string) { + let version = false; + let invalid = false; + const messages: string[] = []; + for (const line of content.split("\n")) { + if (!line) continue; + let raw: unknown; + try { + raw = JSON.parse(line); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + invalid = true; + continue; + } + const event = nativeEventSchema.safeParse(raw); + if (!event.success) { + invalid = true; + continue; + } + if (event.data.type === "version") + version = /^1\.\d+$/.test(event.data.ui ?? ""); + if (!version) { + invalid = true; + continue; + } + if (event.data.type !== "outputs") messages.push(event.data["@message"]); + } + if (invalid) messages.push("[OpenTofu progress event unavailable]"); + return { text: `${messages.join("\n")}\n`, invalid }; +} diff --git a/landing-zone-configurator/app/scripts/test-plan-summary.sh b/landing-zone-configurator/app/scripts/test-plan-summary.sh index cf6751d..f24d429 100644 --- a/landing-zone-configurator/app/scripts/test-plan-summary.sh +++ b/landing-zone-configurator/app/scripts/test-plan-summary.sh @@ -16,22 +16,33 @@ unset TF_CLI_ARGS TF_CLI_ARGS_init TF_CLI_ARGS_validate TF_CLI_ARGS_plan TF_CLI_ export TF_IN_AUTOMATION=true TF_INPUT=0 "$tofu_bin" -chdir="$work_dir" init -input=false -no-color "$tofu_bin" -chdir="$work_dir" validate -no-color -set +e -"$tofu_bin" -chdir="$work_dir" plan -input=false -no-color -detailed-exitcode -out=plan.bin > "$work_dir/plan.log" 2>&1 -plan_exit=$? -set -e +touch "$work_dir/landing-zone.tfvars" +( + cd "$work_dir" + PATH="$(dirname "$tofu_bin"):$PATH" bash "$app_dir/../deploy/runner/run-plan.sh" planning initial-plan-only +) +plan_exit="$(cat "$work_dir/plan.exit")" if [ "$plan_exit" -ne 2 ]; then echo "Plan test failed (exit $plan_exit); raw diagnostics withheld." >&2; exit 1; fi -"$tofu_bin" -chdir="$work_dir" show -json plan.bin > "$work_dir/plan.json" node --input-type=module - "$app_dir" "$work_dir/plan.json" <<'JS' import { readFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; import assert from 'node:assert/strict'; const { summarizePlan } = await import(pathToFileURL(`${process.argv[2]}/apps/worker/dist/plans/summary.js`)); +const { openTofuProgress } = await import(pathToFileURL(`${process.argv[2]}/packages/contracts/dist/index.js`)); const summary = summarizePlan(JSON.parse(readFileSync(process.argv[3], 'utf8')), 2); assert.equal(summary.resources.create, 1); assert.equal(summary.changedOutputs, 1); assert.equal(summary.applyAllowed, false); assert.equal(summary.result, 'changes'); assert.ok(!JSON.stringify(summary).includes('private-spike-value')); -console.log('Real OpenTofu plan summary passed; no apply executed.'); +const directory = new URL('.', pathToFileURL(process.argv[3])); +const progress = openTofuProgress(readFileSync(new URL('plan-events.log', directory), 'utf8')); +assert.equal(progress.invalid, false); +assert.match(progress.text, /Plan: 1 to add/); +assert.ok(!progress.text.includes('private-spike-value')); +const human = readFileSync(new URL('plan.log', directory), 'utf8'); +assert.match(human, /Plan: 1 to add/); +assert.ok(human.includes('private-spike-value'), 'Normal attribute values remain visible in the full plan'); +assert.match(human, /private_output\s*=\s*\(sensitive value\)/); +console.log('Real OpenTofu native UI, full saved-plan text and summary passed; no apply executed.'); JS diff --git a/landing-zone-configurator/app/tests/plan-output-stream.test.ts b/landing-zone-configurator/app/tests/plan-output-stream.test.ts new file mode 100644 index 0000000..0bee4a4 --- /dev/null +++ b/landing-zone-configurator/app/tests/plan-output-stream.test.ts @@ -0,0 +1,196 @@ +import { randomUUID } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; +import type { PlanOutput } from "@lzc/contracts"; +import { expect, it, vi } from "vitest"; +import { buildApp } from "../apps/api/src/app.js"; +import type { AuthServices } from "../apps/api/src/auth/routes.js"; +import type { Session } from "../apps/api/src/auth/store.js"; +import { CredentialError } from "../apps/api/src/credentials/profiles.js"; +import { readPlanOutput } from "../apps/web/src/plan-output.js"; + +async function fixture() { + const session: Session = { + id: randomUUID(), + userId: randomUUID(), + tenantId: randomUUID(), + githubId: "101", + login: "alice", + csrfToken: "c".repeat(43), + expiresAt: new Date(Date.now() + 3600000), + }; + const resolveSession = vi.fn(async (): Promise => session); + const auth: AuthServices = { + origin: "https://configurator.example", + clientId: "test", + store: { + resolveSession, + beginLogin: vi.fn(), + consumeLogin: vi.fn(), + createSession: vi.fn(), + deleteSession: vi.fn(), + }, + github: { authorize: vi.fn() }, + tokens: { get: vi.fn(), put: vi.fn(), remove: vi.fn() }, + }; + const current: { output: PlanOutput } = { + output: { text: "Refreshing state...\n", truncated: false, kind: "live" }, + }; + const output = vi.fn(async () => current.output); + const waitForOutput = vi.fn(async (_id: string, signal: AbortSignal) => + delay(10, undefined, { signal }), + ); + const app = buildApp({ + auth, + plans: { + list: vi.fn(), + start: vi.fn(), + cancel: vi.fn(), + input: vi.fn(), + stage: vi.fn(), + result: vi.fn(), + output, + waitForOutput, + }, + }); + const address = await app.listen({ host: "127.0.0.1", port: 0 }); + const url = `${address}/api/v1/plans/${randomUUID()}/output`; + const headers = { + cookie: `__Host-lzc-session=${"b".repeat(43)}`, + accept: "application/x-ndjson", + }; + return { + app, + url, + headers, + current, + session, + resolveSession, + output, + waitForOutput, + }; +} + +it("delivers output over a real open HTTP connection before the terminal result", async () => { + const f = await fixture(); + try { + const response = await fetch(f.url, { headers: f.headers }); + expect(response.headers.get("content-type")).toContain( + "application/x-ndjson", + ); + expect(response.headers.get("cache-control")).toContain("no-store"); + expect(response.headers.get("x-accel-buffering")).toBe("no"); + const updates: PlanOutput[] = []; + const reading = readPlanOutput(response, (value) => updates.push(value)); + await expect.poll(() => updates.length).toBe(1); + expect(updates[0]?.text).toContain("Refreshing state..."); + expect(f.current.output.kind).toBe("live"); + f.current.output = { + text: "Full saved plan\n", + truncated: false, + kind: "execution", + }; + await reading; + expect(updates.at(-1)).toEqual(f.current.output); + expect(f.resolveSession.mock.calls.length).toBeGreaterThan(1); + } finally { + await f.app.close(); + } +}); + +it.each(["session", "tenant", "role", "owner"] as const)( + "stops an open stream when %s access is revoked", + async (revoked) => { + const f = await fixture(); + try { + const response = await fetch(f.url, { headers: f.headers }); + const updates: PlanOutput[] = []; + const reading = readPlanOutput(response, (value) => updates.push(value)); + await expect.poll(() => updates.length).toBe(1); + const denied = expect(reading).rejects.toThrow( + revoked === "session" || revoked === "tenant" + ? "Bitte erneut anmelden." + : "Keine Berechtigung", + ); + if (revoked === "session") f.resolveSession.mockResolvedValue(null); + else if (revoked === "tenant") + f.resolveSession.mockResolvedValue({ + ...f.session, + tenantId: randomUUID(), + }); + else + f.output.mockRejectedValue( + new CredentialError( + revoked === "role" ? 403 : 404, + revoked === "role" ? "credential_role_required" : "plan_not_found", + ), + ); + await denied; + expect(updates).toHaveLength(1); + } finally { + await f.app.close(); + } + }, +); + +it("rejects unauthenticated streams and releases polling after a disconnect", async () => { + const f = await fixture(); + const controller = new AbortController(); + try { + expect((await fetch(f.url)).status).toBe(401); + expect(f.output).not.toHaveBeenCalled(); + const response = await fetch(f.url, { + headers: f.headers, + signal: controller.signal, + }); + + const updates: PlanOutput[] = []; + const reading = readPlanOutput(response, (value) => updates.push(value)); + await expect.poll(() => updates.length).toBe(1); + const aborted = expect(reading).rejects.toThrow(); + controller.abort(); + await aborted; + await delay(30); + const reads = f.output.mock.calls.length; + await delay(50); + expect(f.output).toHaveBeenCalledTimes(reads); + } finally { + controller.abort(); + f.app.server.closeAllConnections(); + await f.app.close(); + } +}); + +it("bounds concurrent streams per owner and makes the slot reusable after disconnect", async () => { + const f = await fixture(); + const controllers: AbortController[] = []; + try { + for (let index = 0; index < 8; index++) { + const controller = new AbortController(); + controllers.push(controller); + const response = await fetch(f.url, { + headers: f.headers, + signal: controller.signal, + }); + expect(response.status).toBe(200); + } + const denied = await fetch(f.url, { headers: f.headers }); + expect(denied.status).toBe(429); + expect(denied.headers.get("retry-after")).toBe("2"); + expect(await denied.json()).toEqual({ + error: "plan_output_stream_limit", + }); + controllers[0]?.abort(); + await delay(30); + const controller = new AbortController(); + controllers.push(controller); + const resumed = await fetch(f.url, { + headers: f.headers, + signal: controller.signal, + }); + expect(resumed.status).toBe(200); + } finally { + for (const controller of controllers) controller.abort(); + f.app.server.closeAllConnections(); + await f.app.close(); + } +}); diff --git a/landing-zone-configurator/app/tests/plan-output.test.ts b/landing-zone-configurator/app/tests/plan-output.test.ts new file mode 100644 index 0000000..556ca04 --- /dev/null +++ b/landing-zone-configurator/app/tests/plan-output.test.ts @@ -0,0 +1,61 @@ +import type { PlanOutput } from "@lzc/contracts"; +import { expect, it } from "vitest"; +import { readPlanOutput } from "../apps/web/src/plan-output.js"; + +const output: PlanOutput = { + text: "Refreshing state…\n", + kind: "live", + truncated: false, +}; +const frame = (data: PlanOutput) => + `${JSON.stringify({ type: "output", data })}\n`; +const headers = { "content-type": "application/x-ndjson" }; +it("renders fragmented UTF-8 output while the connection is still open", async () => { + let stream: ReadableStreamDefaultController | undefined; + const response = new Response( + new ReadableStream({ + start(controller) { + stream = controller; + }, + }), + { headers }, + ); + const updates: PlanOutput[] = []; + let finished = false; + const reading = readPlanOutput(response, (value) => updates.push(value)); + void reading.then(() => { + finished = true; + }); + const bytes = new TextEncoder().encode(frame(output)); + for (const byte of bytes) stream?.enqueue(Uint8Array.of(byte)); + await expect.poll(() => updates).toEqual([output]); + expect(finished).toBe(false); + stream?.enqueue( + new TextEncoder().encode( + `${frame({ ...output, text: "Complete", kind: "execution" })}{"type":"complete"}\n`, + ), + ); + await reading; + expect(updates.at(-1)?.text).toBe("Complete"); + expect(response.body?.locked).toBe(false); +}); +it.each([ + frame(output), + '{"type":"complete"}\n', + `${frame(output)}{"type":"unknown"}\n`, + `${frame(output)}{"type":"complete"}\n{"type":"heartbeat"}\n`, + '{"private":"DO-NOT-DISPLAY",broken}\n', + frame({ ...output, text: "€".repeat(710000) }), +])( + "rejects incomplete, invalid or oversized streams and releases their reader", + async (body) => { + const response = new Response(body, { headers }); + await expect(readPlanOutput(response, () => {})).rejects.toThrow(); + expect(response.body?.locked).toBe(false); + }, +); +it("preserves JSON snapshots for history and older servers", async () => { + const updates: PlanOutput[] = []; + await readPlanOutput(Response.json(output), (value) => updates.push(value)); + expect(updates).toEqual([output]); +}); diff --git a/landing-zone-configurator/app/tests/platform-plan.test.ts b/landing-zone-configurator/app/tests/platform-plan.test.ts index 65b9b8c..ceb17c7 100644 --- a/landing-zone-configurator/app/tests/platform-plan.test.ts +++ b/landing-zone-configurator/app/tests/platform-plan.test.ts @@ -1287,7 +1287,21 @@ describe.skipIf(!enabled)( await test.plans.stage(ticket, stage); text += `\n${stage}`; } - await test.plans.recordOutput(ticket, { text, truncated: false }); + let notified = false; + const controller = new AbortController(); + const update = test.plans + .waitForOutput(run.id, controller.signal) + .then(() => { + notified = true; + }); + try { + await test.plans.recordOutput(ticket, { text, truncated: false }); + await expect.poll(() => notified, { timeout: 500 }).toBe(true); + await update; + } finally { + controller.abort(); + await update; + } expect(await test.plans.output(test.owner, run.id)).toEqual({ text, truncated: false, diff --git a/landing-zone-configurator/app/tests/platform-worker.test.ts b/landing-zone-configurator/app/tests/platform-worker.test.ts index 4749575..5d257dc 100644 --- a/landing-zone-configurator/app/tests/platform-worker.test.ts +++ b/landing-zone-configurator/app/tests/platform-worker.test.ts @@ -182,17 +182,23 @@ if (args.includes("-migrate-state") && ${JSON.stringify(behavior)} === "migratio } if (phase === "plan") { fs.writeFileSync("plan.bin", ${JSON.stringify(plan.toString())}); - console.log(${JSON.stringify(secret)}); + console.log(JSON.stringify({type:"version", ui:"1.2", "@message":"OpenTofu 1.12.6"})); + console.log(JSON.stringify({type:"diagnostic", "@message":${JSON.stringify(secret)}})); if (${JSON.stringify(behavior)} === "live-output") { - console.log("Refreshing state..."); - process.stdout.write("unfinished-sensitive-line"); + console.log(JSON.stringify({type:"refresh_start", "@message":"Refreshing state...", hook:{private:"private-event-metadata"}})); + process.stdout.write('{"type":"refresh_complete","@message":"unfinished-sensitive-line'); setTimeout(() => { - console.log("\\nRefresh complete"); + console.log('"}'); + console.log(JSON.stringify({type:"refresh_complete", "@message":"Refresh complete"})); process.exit(2); }, 3500); } else process.exit(2); } if (phase === "show") { + if (!args.includes("-json")) { + console.log("Full saved plan: 1 to add, 0 to change, 0 to destroy."); + process.exit(0); + } console.log(JSON.stringify({format_version:"1.2", terraform_version:"1.12.6", errored:false, configuration:{secret:${JSON.stringify(secret)}}, planned_values:{}, resource_changes:[{mode:"managed", change:{actions:[${JSON.stringify(behavior === "destroy" ? "delete" : "create")}], after:{secret:${JSON.stringify(secret)}}}}]})); process.exit(0); } @@ -364,6 +370,7 @@ it.each([undefined, "platform-plan"])( "validate", "plan", "show", + "show", ]); for (const call of calls) { expect(call.tfvarsSha256).toBe(test.input.tfvarsSha256); @@ -465,6 +472,7 @@ it.runIf(Boolean(process.env.LZC_FIREWALL_IMAGE_PACKAGE))( "validate", "plan", "show", + "show", ]); expect(calls.every((call) => call.firewallImageBytes === bytes)).toBe( true, @@ -544,6 +552,7 @@ it("plans the pinned application root with sibling modules and only its instance "validate", "plan", "show", + "show", ]); for (const call of calls) { expect(call.rootMarker).toBe("application"); @@ -582,6 +591,7 @@ it.each(["destroy", "drift"])( "validate", "plan", "show", + "show", ]); const arguments_ = calls.find((call) => call.args[0] === "plan")?.args; expect(arguments_).toContain( @@ -912,37 +922,86 @@ it.runIf(process.env.LZC_NATIVE_APPLICATION_RUNNER_TEST === "true")( 120000, ); -it("publishes redacted progress before a long-running Plan finishes and stops polling after cleanup", async () => { - const test = await fixture("platform-plan", "live-output"); - const execution = test.execute(); - try { - await vi.waitFor( - () => { - const output = test.reports.find(({ path }) => path === "output"); - expect(output?.body).toMatchObject({ - text: expect.stringContaining("Refreshing state..."), - truncated: false, - }); - expect(JSON.stringify(output?.body)).not.toContain(secret); - expect(JSON.stringify(output?.body)).not.toContain(ticket); - expect(JSON.stringify(output?.body)).not.toContain( - "unfinished-sensitive-line", - ); - expect(test.reports.some(({ path }) => path === "result")).toBe(false); - }, - { timeout: 3000 }, +it.each([false, true])( + "publishes progress within 1.5s before Plan completion, deduplicates acknowledged output and retries a failed publication: %s", + async (failFirst) => { + const test = await fixture("platform-plan", "live-output"); + let planningAt = 0; + let firstProgressAt = 0; + let failed = false; + test.options.report = async (path, body) => { + if ( + path === "stage" && + body !== null && + typeof body === "object" && + "stage" in body && + body.stage === "planning" + ) + planningAt = Date.now(); + const progress = + path === "output" && + JSON.stringify(body).includes("Refreshing state..."); + if (progress && !firstProgressAt) firstProgressAt = Date.now(); + const result = await test.report(path, body); + if (failFirst && progress && !failed) { + failed = true; + throw new Error(secret); + } + return result; + }; + const execution = test.execute(); + try { + await vi.waitFor( + () => { + const output = test.reports.find( + ({ path, body }) => + path === "output" && + JSON.stringify(body).includes("Refreshing state..."), + ); + expect(output?.body).toMatchObject({ + text: expect.stringContaining("Refreshing state..."), + truncated: false, + }); + expect(JSON.stringify(output?.body)).not.toContain(secret); + expect(JSON.stringify(output?.body)).not.toContain(ticket); + expect(JSON.stringify(output?.body)).not.toContain( + "unfinished-sensitive-line", + ); + expect(test.reports.some(({ path }) => path === "result")).toBe( + false, + ); + expect(planningAt).toBeGreaterThan(0); + expect(firstProgressAt - planningAt).toBeLessThan(1500); + }, + { timeout: 3000 }, + ); + } finally { + expect(await execution).toBe("succeeded"); + } + const output = test.reports.findLast(({ path }) => path === "output"); + expect(output?.body).toMatchObject({ + text: expect.stringContaining("Refresh complete"), + }); + expect(output?.body).toMatchObject({ + text: expect.stringContaining("Full saved plan: 1 to add"), + }); + expect(JSON.stringify(output?.body)).not.toContain( + "private-event-metadata", ); - } finally { - expect(await execution).toBe("succeeded"); - } - const output = test.reports.findLast(({ path }) => path === "output"); - expect(output?.body).toMatchObject({ - text: expect.stringContaining("Refresh complete"), - }); - const count = test.reports.length; - await new Promise((resolve) => setTimeout(resolve, 2100)); - expect(test.reports).toHaveLength(count); -}, 10000); + expect( + test.reports.filter( + ({ path, body }) => + path === "output" && + JSON.stringify(body).includes("Refreshing state...") && + !JSON.stringify(body).includes("Refresh complete"), + ), + ).toHaveLength(failFirst ? 2 : 1); + const count = test.reports.length; + await new Promise((resolve) => setTimeout(resolve, 2100)); + expect(test.reports).toHaveLength(count); + }, + 10000, +); it.each(["success", "apply-failure", "migration-failure"])( "publishes redacted Apply output before the terminal result and cleanup: %s", @@ -1061,6 +1120,7 @@ it("uploads only the saved artifact and sanitized counts before platform-plan su "validate", "plan", "show", + "show", ]); expect(calls[0]?.args).toContain("-lockfile=readonly"); expect(calls.every((call) => call.http)).toBe(true); @@ -1119,6 +1179,7 @@ it.each(["initial-plan-only", undefined])( "validate", "plan", "show", + "show", ]); expect(calls[0]?.args).toContain("-backend=false"); expect(calls.every((call) => !call.http)).toBe(true); @@ -1242,7 +1303,7 @@ it.each(["platform-plan", "platform-apply"])( const calls = await test.calls(); expect(calls.map((call) => call.args[0])).toEqual( mode === "platform-plan" - ? ["init", "validate", "plan", "show"] + ? ["init", "validate", "plan", "show", "show"] : ["init", "validate", "apply"], ); expect(calls[0]?.args).toEqual([ diff --git a/landing-zone-configurator/app/tests/runner-progress.test.ts b/landing-zone-configurator/app/tests/runner-progress.test.ts new file mode 100644 index 0000000..0249a14 --- /dev/null +++ b/landing-zone-configurator/app/tests/runner-progress.test.ts @@ -0,0 +1,47 @@ +import { openTofuProgress } from "@lzc/contracts"; +import { expect, it } from "vitest"; + +const version = JSON.stringify({ + type: "version", + ui: "1.2", + "@message": "OpenTofu 1.12.6", +}); +it("projects only native human messages, never raw plan metadata or output values", () => { + const result = openTofuProgress( + [ + version, + JSON.stringify({ + type: "refresh_start", + "@message": "Refreshing state...", + hook: { private: "private-plan-value" }, + }), + JSON.stringify({ + type: "outputs", + "@message": "private-output-value", + outputs: { secret: "private-output-value" }, + }), + JSON.stringify({ + type: "future_event", + "@message": "New progress message", + }), + ].join("\n"), + ); + expect(result).toEqual({ + text: "OpenTofu 1.12.6\nRefreshing state...\nNew progress message\n", + invalid: false, + }); +}); +it.each([ + '{"type":"version","ui":"2.0","@message":"private-value"}', + '{"type":"refresh_start","@message":"private-value"}', + `${version}\n{"private":"private-value"}`, + `${version}\n{"broken":"private-value"`, +])( + "surfaces invalid or unsupported UI events without copying their raw values", + (input) => { + const result = openTofuProgress(input); + expect(result.invalid).toBe(true); + expect(result.text).toContain("[OpenTofu progress event unavailable]"); + expect(result.text).not.toContain("private-value"); + }, +); diff --git a/landing-zone-configurator/deploy/runner/run-plan.sh b/landing-zone-configurator/deploy/runner/run-plan.sh index 6373af7..af3e9ab 100644 --- a/landing-zone-configurator/deploy/runner/run-plan.sh +++ b/landing-zone-configurator/deploy/runner/run-plan.sh @@ -59,12 +59,13 @@ case "${1:-}" in application-drift-plan) plan_flag="-refresh=true" ;; esac set +e - tofu plan ${plan_flag:+"$plan_flag"} -input=false -no-color -parallelism=4 -lock-timeout=0s -detailed-exitcode -var-file=landing-zone.tfvars -out=plan.bin > plan.log 2>&1 + tofu plan ${plan_flag:+"$plan_flag"} -json -input=false -no-color -parallelism=4 -lock-timeout=0s -detailed-exitcode -var-file=landing-zone.tfvars -out=plan.bin > plan-events.log 2>&1 plan_exit=$? set -e if [ "$plan_exit" -ne 0 ] && [ "$plan_exit" -ne 2 ]; then exit 1; fi printf '%s' "$plan_exit" > plan.exit tofu show -json plan.bin > plan.json 2> show.log + tofu show -no-color plan.bin > plan.log 2>> show.log ;; applying) exec tofu apply -input=false -no-color -parallelism=4 -lock-timeout=0s saved-plan.bin > apply.log 2>&1 diff --git a/landing-zone-configurator/docs/development.md b/landing-zone-configurator/docs/development.md index 898d5a0..d04281e 100644 --- a/landing-zone-configurator/docs/development.md +++ b/landing-zone-configurator/docs/development.md @@ -44,6 +44,15 @@ replace the allowlist with a catch-all. ## Validation versus cloud operations +Runner output uses an authenticated NDJSON stream with unchanged JSON snapshot +compatibility. Keep the shared output-event contract, worker redaction and UTF-8 +byte limits, native UI-event projection and final human-readable saved Plan in +sync. Test output before process completion and before HTTP EOF, access +revocation, disconnect cleanup and desktop/mobile collapse/reconnect. Do not +replace native progress with invented percentages or expose raw machine UI, +saved-plan JSON or state. Assistant acceptance and output viewing grant no +execution permission. + | Workflow | Trigger and purpose | | --- | --- | | Validate | Every PR and main push; required Accelerator check names remain present, with change-aware execution | diff --git a/landing-zone-configurator/docs/plan-execution.md b/landing-zone-configurator/docs/plan-execution.md index 65bc36c..68988e0 100644 --- a/landing-zone-configurator/docs/plan-execution.md +++ b/landing-zone-configurator/docs/plan-execution.md @@ -181,7 +181,7 @@ des bisherigen Standalone-Vertrags stehen im - Ergebnisprojektion enthält nur Zahlen, Status und feste Fehlercodes; keine Adressen, Attributwerte, Output-Namen, Rohdiagnosen oder Geheimnisse. Auch nicht als sensitive markierte Werte werden nicht in diese Summary exportiert. - Separat zeigt der native lokale Runner dem berechtigten Plan-Eigentümer unter + Separat zeigt der Runner dem berechtigten Plan-Eigentümer unter **OpenTofu-Ausgabe** die normale CLI-Textdarstellung: Live-Logs aus dem privaten aktiven Job oder `tofu show -no-color` des exakt hash-/paketgebundenen, verschlüsselt gespeicherten Planartefakts. Sitzung, Deployment-Rolle und @@ -192,11 +192,34 @@ des bisherigen Standalone-Vertrags stehen im serverseitig. Es laufen kein neuer Cloud-Plan, kein Apply und kein Backend-Init; die Inspektion verwendet nur backendfreies Init und Show ohne geerbte Secrets. Die Apply-Ablauffrist bleibt unabhängig von der read-only Anzeige, solange das - verschlüsselte Artefakt vorhanden ist. CF-Live-Ausgabetransport bleibt offen. -- Vor dem terminalen Ergebnis sichert der Worker den redigierten CLI-Mitschnitt + verschlüsselte Artefakt vorhanden ist. +- Aktive Plattformaufträge öffnen einen NDJSON-Live-Kanal auf + `GET /api/v1/plans/:id/output` mit `Accept: application/x-ndjson`. + JSON-Snapshots bleiben für Historie und bestehende Clients erhalten. Der Worker + liest vollständige Log-Zeilen alle 500 ms und überträgt nur geänderte, + redigierte Mitschnitte. Nach dem verschlüsselten Commit benachrichtigt die API + ihre lokalen Abonnenten; andere API-Instanzen prüfen spätestens nach einer + Sekunde erneut. Unveränderte Ausgabe erzeugt nur ein Heartbeat, keinen neuen + Mitschnitt. Sitzung, Workspace, Deployment-Rolle und Eigentümerschaft werden + während der Verbindung erneut geprüft, nicht nur beim Öffnen. + Verbindungen enden nach spätestens 35 Sekunden und werden bei weiterhin + aktivem Auftrag erneut geöffnet. Collapse, Navigation und Abmeldung brechen + sie ab. Pro Eigentümer/Tenant sind je API-Instanz höchstens acht Verbindungen + erlaubt; langsame Leser erzeugen Backpressure statt unbegrenzter Warteschlangen. +- Der Plan verwendet OpenTofus native `-json`-UI-Ereignisse für tatsächliche + Fortschrittsmeldungen, auch bevor der fertige Diff existiert. Nur menschliche + Meldungen des unterstützten UI-Protokolls werden projiziert; rohe Ereignisse, + Metadaten und Output-Werte werden nicht an den Browser gesendet. + Unvollständige Zeilen bleiben zurückgehalten, ungültige Ereignisse erhalten + einen sichtbaren festen Fehlerhinweis. Anschließend ergänzt + `tofu show -no-color plan.bin` den vollständigen normalen Plantext. + Init, Validate, Apply und Migration behalten ihre native CLI-Ausgabe. + Es gibt keine geschätzten Prozentwerte: wenn OpenTofu während einer Operation + keine Meldung erzeugt, bleiben Phase und Laufzeit sichtbar. +- Während des Laufs und vor dem terminalen Ergebnis sichert der Worker den redigierten CLI-Mitschnitt von Init/Validate/Plan/Apply/Migration über `POST /api/runner/output`. Das auftragsspezifische Ticket muss gültig und die Eingabe bereits beansprucht sein; - der Text darf nur einmal vor Abschluss geschrieben werden. Migration 020 legt + der Text darf bis zum Abschluss aktualisiert werden, danach ist er eingefroren. Migration 020 legt dafür private Output-Spalten unter der bestehenden Eigentümer-/Tenant-RLS an. Der Broker verschlüsselt maximal 2 MiB mit AEAD und auftragsspezifischer AAD. Erfolgs- und Fehlerausgaben bleiben nach Cleanup und Reload lesbar, ohne laufenden diff --git a/landing-zone-configurator/docs/user-guide.md b/landing-zone-configurator/docs/user-guide.md index bc05af0..f42b0ae 100644 --- a/landing-zone-configurator/docs/user-guide.md +++ b/landing-zone-configurator/docs/user-guide.md @@ -64,6 +64,14 @@ Choose the correct environment and target, bind the authorized technical credentials, then create a preparation and request a Plan. Follow the job output and review the resulting changes, source/configuration version and state binding. +Active platform jobs stream **OpenTofu output** as it becomes available, including +native Plan progress before the final diff. **Follow output** can be switched off +to inspect earlier lines. Collapse the console to disconnect; reopening shows +the latest captured output without restarting the job. The final normal Plan +text remains available after completion and reload. Output is limited to 2 MiB; +truncation and connection errors are shown. OpenTofu may remain quiet during a +long operation: phase and elapsed time are not an estimated percentage. + Apply uses the reviewed saved Plan and requires its own authorization. Changed inputs, source, state or an expired approval can require a new preparation. Do not start parallel infrastructure writes to the same state.