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
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
-- Progress snapshots may change only while their bound dispatch is active.
-- Saved Plans and recovery evidence keep their existing immutability contract.
CREATE FUNCTION lzc.guard_application_record() RETURNS trigger
LANGUAGE plpgsql SET search_path=pg_catalog AS $$
BEGIN
IF TG_OP='UPDATE' AND OLD.kind='output' AND
(to_jsonb(NEW)-ARRAY['ciphertext','sha256','truncated']) IS NOT DISTINCT FROM
(to_jsonb(OLD)-ARRAY['ciphertext','sha256','truncated']) AND EXISTS(
SELECT 1 FROM lzc.application_dispatches d
WHERE d.job_id=OLD.job_id AND d.tenant_id=OLD.tenant_id
AND d.owner_user_id=OLD.owner_user_id AND d.runner_package_id=OLD.runner_package_id
AND d.status IN ('starting','initializing','validating','planning','applying')
) THEN RETURN NEW; END IF;
RAISE EXCEPTION 'immutable_application_version' USING ERRCODE='55000';
END $$;
REVOKE ALL ON FUNCTION lzc.guard_application_record() FROM PUBLIC;
DROP TRIGGER immutable_application_record ON lzc.application_runner_records;
CREATE TRIGGER immutable_application_record BEFORE UPDATE OR DELETE ON lzc.application_runner_records
FOR EACH ROW EXECUTE FUNCTION lzc.guard_application_record();

-- Keep all existing session, delegation, claim, ticket and dispatch checks.
-- Only the output case gains replacement; artifact/recovery conflicts still fail.
DO $$
DECLARE signature text; definition text; anchor text; replacement text;
BEGIN
signature := 'lzc_auth.application_report_record(uuid,uuid,text,bytea,text,jsonb,boolean)';
definition := pg_get_functiondef(signature::regprocedure);
anchor := 'IF existing.sha256 IS DISTINCT FROM p_sha';
replacement := 'IF p_kind=''output'' THEN
UPDATE lzc.application_runner_records SET ciphertext=p_cipher,sha256=p_sha,truncated=p_truncated WHERE job_id=p_job AND kind=''output'';
RETURN p_sha;
END IF;
' || anchor;
IF strpos(definition,anchor)=0 THEN RAISE EXCEPTION 'application_output_guard_missing'; END IF;
EXECUTE replace(definition,anchor,replacement);
FOREACH signature IN ARRAY ARRAY[
'lzc_auth.delegated_application_plan_operation(uuid,uuid,text,jsonb)',
'lzc_auth.delegated_application_operation(uuid,uuid,text,jsonb)'
] LOOP
definition := pg_get_functiondef(signature::regprocedure);
anchor := 'IF existing.sha256 IS DISTINCT FROM p_input->>''sha''';
replacement := 'IF p_input->>''kind''=''output'' THEN
UPDATE lzc.application_runner_records SET ciphertext=decode(p_input->>''cipher'',''hex''),sha256=p_input->>''sha'',truncated=(p_input->>''truncated'')::boolean WHERE job_id=p_job AND kind=''output'';
RETURN jsonb_build_object(''sha256'',p_input->>''sha'');
END IF;
' || anchor;
IF strpos(definition,anchor)=0 THEN RAISE EXCEPTION 'application_output_guard_missing'; END IF;
EXECUTE replace(definition,anchor,replacement);
END LOOP;
END $$;
Original file line number Diff line number Diff line change
Expand Up @@ -941,15 +941,24 @@ function ApplicationPlanControls({
job.status,
),
);
const outputJob =
view === "destroy"
? jobs.find(
(job) => job.id === currentPlan?.id || job.id === currentApply?.id,
)
: view === "apply"
? currentApply
: planView
? currentPlan
: undefined;
const outputJobId =
(view === "apply" || view === "destroy") &&
currentApply?.requestedBy === session.user.id
? currentApply?.id
outputJob && outputJob.requestedBy === session.user.id
? outputJob.id
: undefined;
const outputPolling = Boolean(
currentApply &&
outputJob &&
!["succeeded", "failed", "reconciliation_required"].includes(
currentApply.status,
outputJob.status,
),
);
const loadOutput = useCallback(
Expand Down Expand Up @@ -1258,41 +1267,39 @@ function ApplicationPlanControls({
)}
</>
)}
{(view === "apply" || view === "destroy") &&
job.operation === "apply" &&
job.requestedBy === session.user.id && (
<details open className="application-output">
<summary>{t("Ausführungslogs")}</summary>
<button
type="button"
className="button secondary icon-button"
aria-label={t("Ausführungslogs aktualisieren")}
title={t("Ausführungslogs aktualisieren")}
onClick={() =>
void loadOutput(job.id).catch((cause: unknown) =>
setOutputError(
cause instanceof Error
? cause.message
: "Die Ausführungslogs sind derzeit nicht verfügbar.",
),
)
}
>
<RefreshCw size={18} aria-hidden="true" />
</button>
{outputError && <p role="alert">{t(outputError)}</p>}
{output?.jobId === job.id && output.text ? (
<section aria-label={t("Ausführungslogs")}>
<pre>{output.text}</pre>
</section>
) : (
<p>{t("Noch keine Ausführungslogs verfügbar.")}</p>
)}
{output?.jobId === job.id && output.truncated && (
<p>{t("Die Ausführungslogs wurden gekürzt.")}</p>
)}
</details>
)}
{job.id === outputJobId && job.requestedBy === session.user.id && (
<details open className="application-output">
<summary>{t("Ausführungslogs")}</summary>
<button
type="button"
className="button secondary icon-button"
aria-label={t("Ausführungslogs aktualisieren")}
title={t("Ausführungslogs aktualisieren")}
onClick={() =>
void loadOutput(job.id).catch((cause: unknown) =>
setOutputError(
cause instanceof Error
? cause.message
: "Die Ausführungslogs sind derzeit nicht verfügbar.",
),
)
}
>
<RefreshCw size={18} aria-hidden="true" />
</button>
{outputError && <p role="alert">{t(outputError)}</p>}
{output?.jobId === job.id && output.text ? (
<section aria-label={t("Ausführungslogs")}>
<pre>{output.text}</pre>
</section>
) : (
<p>{t("Noch keine Ausführungslogs verfügbar.")}</p>
)}
{output?.jobId === job.id && output.truncated && (
<p>{t("Die Ausführungslogs wurden gekürzt.")}</p>
)}
</details>
)}
{job.requestedBy === session.user.id &&
(job.status === "reconciliation_required" ||
(job.operation === "apply" && job.status === "failed")) && (
Expand Down
2 changes: 1 addition & 1 deletion landing-zone-configurator/app/apps/worker/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,7 @@ export async function runWorker(
if (!outputStopped) outputTimer = setTimeout(pollOutput, 500);
});
};
if (broker === "platform") outputTimer = setTimeout(pollOutput, 500);
outputTimer = setTimeout(pollOutput, 500);
try {
await new Promise<void>((done, fail) => {
const child = spawn(
Expand Down
92 changes: 78 additions & 14 deletions landing-zone-configurator/app/e2e/organisation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ for (const scenario of [
"disabled",
"engineer",
"dispatch-failed",
"plan-failed",
"prepare-retry",
"prepare-refresh-retry",
"delete-owner",
Expand All @@ -35,6 +36,7 @@ for (const scenario of [
"archive-blocked",
"order-table",
"maintenance-destroy",
"maintenance-replan-failed",
"maintenance-drift",
"maintenance-expired",
"maintenance-running",
Expand All @@ -48,15 +50,22 @@ for (const scenario of [
const backendId = "88888888-8888-4888-8888-888888888888";
const maintenance = scenario.startsWith("maintenance-");
const recoveryReview = scenario.startsWith("recovery-review");
const purpose = scenario === "maintenance-destroy" ? "destroy" : "drift";
let maintenanceStarted = false;
const purpose =
scenario === "maintenance-destroy" ||
scenario === "maintenance-replan-failed"
? "destroy"
: "drift";
const failedPlan =
scenario === "plan-failed" || scenario === "maintenance-replan-failed";
let maintenanceStarted = scenario === "maintenance-replan-failed";
const initialApply =
recoveryReview ||
scenario.startsWith("apply-") ||
scenario.startsWith("archive-") ||
scenario === "order-table" ||
scenario === "maintenance-expired" ||
scenario === "maintenance-running";
scenario === "maintenance-running" ||
scenario === "maintenance-replan-failed";
const engineer =
recoveryReview ||
scenario === "engineer" ||
Expand Down Expand Up @@ -108,7 +117,7 @@ for (const scenario of [
reason: "Reviewed",
},
};
let prepared = engineer || initialApply;
let prepared = engineer || initialApply || scenario === "plan-failed";
let backendApproved = scenario === "delegated-owner" || initialApply;
let dispatched = scenario === "delete-started" || initialApply;
let deleted = false;
Expand Down Expand Up @@ -405,9 +414,11 @@ for (const scenario of [
).toISOString(),
operation: "apply",
purpose:
maintenanceStarted && !initialApply
? purpose
: "standard",
scenario === "maintenance-replan-failed"
? "destroy"
: maintenanceStarted && !initialApply
? purpose
: "standard",
planId: jobId,
status:
scenario === "apply-running" ||
Expand Down Expand Up @@ -442,11 +453,13 @@ for (const scenario of [
: "99999999-9999-4999-8999-999999999999",
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 120000).toISOString(),
status: dispatched
? succeeded
? "succeeded"
: "planning"
: "prepared",
status: failedPlan
? "failed"
: dispatched
? succeeded
? "succeeded"
: "planning"
: "prepared",
grantActive: true,
backendId: backendApproved ? backendId : null,
canApproveBackend:
Expand All @@ -455,7 +468,7 @@ for (const scenario of [
(engineer || delegated) && backendApproved && !dispatched,
...(delegated ? { delegatedExecution: true } : {}),
summary: succeeded ? summary : null,
errorCode: null,
errorCode: failedPlan ? "plan_failed" : null,
canApply:
delegated &&
succeeded &&
Expand All @@ -464,16 +477,30 @@ for (const scenario of [
artifactSha256: succeeded ? "a".repeat(64) : null,
},
];
if (applied) {
if (applied && scenario !== "maintenance-replan-failed") {
const plan = jobs.find((job) => job.id === jobId);
if (plan)
jobs.push({
...plan,
id: "bbbbbbb2-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
});
}
if (scenario === "maintenance-replan-failed") jobs.reverse();
return route.fulfill({ json: { jobs } });
}
if (path === `jobs/${jobId}/output`) {
expect(request.headers()["x-lzc-tenant"]).toBe(tenantId);
expect(requesterId).toBe(userId);
return route.fulfill({
json: {
text: failedPlan
? "Error: Corporate plan fixture failure"
: "Refreshing application project...",
truncated: false,
kind: failedPlan ? "execution" : "live",
},
});
}
if (path === "jobs/aaaaaaa1-aaaa-4aaa-8aaa-aaaaaaaaaaaa/output")
return route.fulfill({
json: {
Expand Down Expand Up @@ -781,6 +808,43 @@ for (const scenario of [
return;
}
const plan = await details();
if (scenario === "plan-failed") {
await expect(plan.getByRole("alert")).toContainText("plan_failed");
const logs = plan.getByRole("region", {
name: "Ausführungslogs",
exact: true,
});
await expect(logs).toContainText("Error: Corporate plan fixture failure");
await plan
.getByRole("button", {
name: "Ausführungslogs aktualisieren",
exact: true,
})
.click();
await expect(logs).toContainText("Error: Corporate plan fixture failure");
expect(mutations).toEqual([]);
await page.getByLabel("Sprache", { exact: true }).selectOption("en");
await expect(
page
.getByRole("region", { name: "Application plan", exact: true })
.getByRole("region", { name: "Execution logs", exact: true }),
).toContainText("Corporate plan fixture failure");
return;
}
if (scenario === "maintenance-replan-failed") {
await page
.getByRole("region", { name: "Bestelldetails", exact: true })
.getByRole("tab", { name: "Destroy", exact: true })
.click();
await expect(
plan.getByRole("region", { name: "Ausführungslogs", exact: true }),
).toContainText("Error: Corporate plan fixture failure");
await expect(
plan.getByRole("region", { name: "Ausführungslogs", exact: true }),
).not.toContainText("Error creating credentials group");
expect(mutations).toEqual([]);
return;
}
if (maintenance) {
const orderDetails = page.getByRole("region", {
name: "Bestelldetails",
Expand Down
Loading
Loading