+ );
+}
type Preparation = {
id: string;
name: string;
@@ -587,27 +624,35 @@ function PlanReview({
)}
{!allowed && (
-
- {blocked
- ? t(
- "Freigabe gesperrt. Prüfe den Ausführungsstatus und erstelle bei Bedarf einen neuen Plan.",
- )
- : Date.parse(sessionExpiresAt) <= Date.now()
+ <>
+
+
+ {blocked
? t(
- "Sitzung abgelaufen. Bitte erneut anmelden und den Plan prüfen.",
+ "Freigabe gesperrt. Prüfe den Ausführungsstatus und erstelle bei Bedarf einen neuen Plan.",
)
- : expired
+ : Date.parse(sessionExpiresAt) <= Date.now()
? t(
- "Plan abgelaufen oder keine Gültigkeit bestätigt. Erstelle einen neuen Plan.",
+ "Sitzung abgelaufen. Bitte erneut anmelden und den Plan prüfen.",
)
- : run.summary?.completeness === "not-reported"
+ : expired
? t(
- "Apply ist gesperrt: Der Plan enthält keinen bestätigten Vollständigkeitsnachweis der Engine.",
+ "Plan abgelaufen oder keine Gültigkeit bestätigt. Erstelle einen neuen Plan.",
)
- : t(
- "Apply ist für diesen Plan nicht vom Server freigegeben oder die Plan-Nachweise sind unvollständig.",
- )}
-
+ : run.summary?.completeness === "not-reported"
+ ? t(
+ "Apply ist gesperrt: Der Plan enthält keinen bestätigten Vollständigkeitsnachweis der Engine.",
+ )
+ : run.applyBlocker
+ ? null
+ : t(
+ "Apply ist für diesen Plan nicht vom Server freigegeben oder die Plan-Nachweise sind unvollständig.",
+ )}
+
+
+ >
)}
{allowed && (
<>
@@ -1179,6 +1224,11 @@ export function PlanRuns({
)}
)}
+ {!readOnly &&
+ phase === "plan" &&
+ !historical &&
+ run.operation !== "apply" &&
+ run.status === "succeeded" && }
{!readOnly &&
phase === "apply" &&
!historical &&
diff --git a/landing-zone-configurator/app/apps/web/src/locales/en.ts b/landing-zone-configurator/app/apps/web/src/locales/en.ts
index 5e1dc85..3bb11a7 100644
--- a/landing-zone-configurator/app/apps/web/src/locales/en.ts
+++ b/landing-zone-configurator/app/apps/web/src/locales/en.ts
@@ -715,6 +715,19 @@ export const englishMessages: Record = {
"Apply failed. Resources may already have changed. Check execution status before taking further action.",
"Apply ist für diesen Plan nicht vom Server freigegeben oder die Plan-Nachweise sind unvollständig.":
"The server has not authorized Apply for this plan, or the plan evidence is incomplete.",
+ "Apply derzeit gesperrt": "Apply currently blocked",
+ "Diese Konfiguration kann geplant, aber mit den enthaltenen Komponenten noch nicht angewendet werden. Die gesperrten Felder sind unten aufgeführt. Ein neuer Plan allein hebt diese Sperre nicht auf.":
+ "This configuration can be planned, but its components are not yet supported for Apply. The blocked fields are listed below. Creating another plan alone will not remove this restriction.",
+ "Die Konfiguration wurde seit der Vorbereitung geändert. Erstelle eine neue Vorbereitung und einen neuen Plan.":
+ "The configuration has changed since preparation. Create a new preparation and a new plan.",
+ "Die gespeicherte Konfiguration ist nicht mehr verfügbar.":
+ "The saved configuration is no longer available.",
+ "Verbinde GitHub erneut, damit die Quelle dieses Plans geprüft werden kann.":
+ "Reconnect GitHub so this plan's source can be verified.",
+ "Deine aktuelle Rolle erlaubt keine Apply-Freigabe.":
+ "Your current role does not allow Apply approval.",
+ "Firewall-/HA-Infrastruktur ist nur für Plan freigegeben. Apply erfordert einen separat qualifizierten Administrations-/Bootstrap-Vertrag; Policy und VPN-PSKs bleiben gesondert gesperrt.":
+ "Firewall/HA infrastructure is approved for Plan only. Apply requires a separately qualified administration/bootstrap contract; policy and VPN pre-shared keys remain separately blocked.",
"Apply ist gesperrt: Der Plan enthält keinen bestätigten Vollständigkeitsnachweis der Engine.":
"Apply is blocked: the plan has no engine-confirmed completeness evidence.",
"Apply ist in dieser Umgebung nicht aktiviert.":
diff --git a/landing-zone-configurator/app/e2e/configurator.spec.ts b/landing-zone-configurator/app/e2e/configurator.spec.ts
index 9970c57..a3dc28e 100644
--- a/landing-zone-configurator/app/e2e/configurator.spec.ts
+++ b/landing-zone-configurator/app/e2e/configurator.spec.ts
@@ -1,6 +1,7 @@
import { readFile } from "node:fs/promises";
import {
assistantProposalFields,
+ type PlatformApplyBlocker,
platformUpgradeAcceleratorCommit,
selectAssistantProposalFields,
} from "@lzc/contracts";
@@ -5186,7 +5187,7 @@ test.describe("explicit saved-plan approval", () => {
await expect(page.getByRole("alert")).toContainText(message as string);
await expect(
page.getByRole("button", { name: "Freigegebenen Plan anwenden" }),
- ).toHaveCount(0);
+ ).toBeDisabled();
await page
.getByRole("navigation", { name: "Konfiguration", exact: true })
.getByRole("button", { name: "Plan", exact: true })
@@ -5204,7 +5205,7 @@ test.describe("explicit saved-plan approval", () => {
.click();
await expect(
page.getByRole("button", { name: "Freigegebenen Plan anwenden" }),
- ).toHaveCount(0);
+ ).toBeDisabled();
expect(calls).toBe(1);
await expect(page.getByText(/Apply: Änderungen angewendet/)).toHaveCount(
0,
@@ -5977,6 +5978,91 @@ test.describe("explicit saved-plan approval", () => {
await expect(page.locator(".plan-runs > article")).toHaveCount(1);
});
+ test("shows the server's Apply blocker on both phases and restores explicit approval only for an eligible plan", async ({
+ page,
+ }) => {
+ await setup(page);
+ const plan: ReturnType & {
+ applyBlocker: PlatformApplyBlocker | null;
+ } = {
+ ...savedPlan(),
+ applyAllowed: false,
+ applyBlocker: {
+ code: "configuration_apply_not_supported",
+ issues: [
+ {
+ field: "connectivity.firewalls",
+ message:
+ "Firewall-/HA-Infrastruktur ist nur für Plan freigegeben. Apply erfordert einen separat qualifizierten Administrations-/Bootstrap-Vertrag; Policy und VPN-PSKs bleiben gesondert gesperrt.",
+ },
+ ],
+ },
+ };
+ let mutations = 0;
+ await page.route("**/api/v1/plans", (route) => {
+ if (route.request().method() !== "GET") mutations++;
+ return route.fulfill({ json: { runs: [plan] } });
+ });
+ await page.route(`**/api/v1/plans/${planId}/apply`, (route) => {
+ mutations++;
+ return route.fulfill({
+ status: 409,
+ json: { error: "apply_not_allowed" },
+ });
+ });
+ await page.goto("/deployments/plan");
+ await expect(
+ page.getByText("Apply derzeit gesperrt", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByText("connectivity.firewalls", { exact: true }),
+ ).toBeVisible();
+ await expect(page.getByText(/nur für Plan freigegeben/)).toBeVisible();
+ await page
+ .getByRole("button", { name: "Weiter zu Apply", exact: true })
+ .click();
+ const apply = page.getByRole("button", {
+ name: "Freigegebenen Plan anwenden",
+ exact: true,
+ });
+ await expect(apply).toBeVisible();
+ await expect(apply).toBeDisabled();
+ await expect(
+ page.getByRole("checkbox", {
+ name: "Änderungen und Zielorganisation geprüft",
+ }),
+ ).toHaveCount(0);
+ await page.reload();
+ await expect(apply).toBeDisabled();
+ await expect(page.getByText(/nur für Plan freigegeben/)).toBeVisible();
+ await page.getByLabel("Sprache", { exact: true }).selectOption("en");
+ await expect(
+ page.getByText("Apply currently blocked", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByText(/Firewall\/HA infrastructure is approved for Plan only/),
+ ).toBeVisible();
+ await page.getByLabel("Language", { exact: true }).selectOption("de");
+ plan.applyAllowed = true;
+ plan.applyBlocker = null;
+ await page
+ .getByRole("button", { name: "Ausführungsstatus aktualisieren" })
+ .click();
+ await expect(
+ page.getByText("Apply derzeit gesperrt", { exact: true }),
+ ).toHaveCount(0);
+ await page
+ .getByRole("checkbox", {
+ name: "Änderungen und Zielorganisation geprüft",
+ })
+ .check();
+ await page
+ .getByRole("textbox", { name: "Zielorganisation UUID bestätigen" })
+ .fill(organizationId);
+ await expect(apply).toBeEnabled();
+ expect(mutations).toBe(0);
+ });
+
test("legacy summary cannot grant apply, expired plan and invalid export stay unavailable", async ({
page,
}) => {
@@ -5993,7 +6079,7 @@ test.describe("explicit saved-plan approval", () => {
await expect(page.getByText(/nicht vom Server freigegeben/)).toBeVisible();
await expect(
page.getByRole("button", { name: "Freigegebenen Plan anwenden" }),
- ).toHaveCount(0);
+ ).toBeDisabled();
plan.applyAllowed = true;
plan.expiresAt = new Date(Date.now() - 1000).toISOString();
await page
diff --git a/landing-zone-configurator/app/packages/contracts/src/index.ts b/landing-zone-configurator/app/packages/contracts/src/index.ts
index f6f0152..b085c22 100644
--- a/landing-zone-configurator/app/packages/contracts/src/index.ts
+++ b/landing-zone-configurator/app/packages/contracts/src/index.ts
@@ -111,7 +111,12 @@ export const appliedPlatformSourceSchema = z.strictObject({
});
export type AppliedPlatformSource = z.infer;
-export type { ActionCounts, PlanAction, PlanSummary } from "./plan.js";
+export type {
+ ActionCounts,
+ PlanAction,
+ PlanSummary,
+ PlatformApplyBlocker,
+} from "./plan.js";
export {
applicationMvpAcceleratorCommit,
applicationPlanPreview,
diff --git a/landing-zone-configurator/app/packages/contracts/src/plan.ts b/landing-zone-configurator/app/packages/contracts/src/plan.ts
index 607e7a9..fde7af7 100644
--- a/landing-zone-configurator/app/packages/contracts/src/plan.ts
+++ b/landing-zone-configurator/app/packages/contracts/src/plan.ts
@@ -46,6 +46,11 @@ export interface PlanSummary {
completeness: "complete" | "incomplete" | "not-reported";
}
+export interface PlatformApplyBlocker {
+ code: string;
+ issues?: { field: string; message: string }[];
+}
+
export class InvalidPlan extends Error {
constructor() {
super(
diff --git a/landing-zone-configurator/app/tests/platform-plan.test.ts b/landing-zone-configurator/app/tests/platform-plan.test.ts
index 5c0abcf..65b9b8c 100644
--- a/landing-zone-configurator/app/tests/platform-plan.test.ts
+++ b/landing-zone-configurator/app/tests/platform-plan.test.ts
@@ -27,7 +27,10 @@ import {
type Session,
tokenHash,
} from "../apps/api/src/auth/store.js";
-import { PostgresCredentialProfiles } from "../apps/api/src/credentials/profiles.js";
+import {
+ CredentialError,
+ PostgresCredentialProfiles,
+} from "../apps/api/src/credentials/profiles.js";
import { Backends, contentHash } from "../apps/api/src/deployments/backends.js";
import {
acceleratorCommit,
@@ -557,6 +560,18 @@ describe.skipIf(!enabled)(
(await test.plans.list(test.owner)).find((item) => item.id === run.id)
?.applyAllowed,
).toBe(false);
+ expect(
+ (await test.plans.list(test.owner)).find((item) => item.id === run.id)
+ ?.applyBlocker,
+ ).toEqual({
+ code: "configuration_apply_not_supported",
+ issues: [
+ {
+ field: "connectivity.firewall",
+ message: expect.stringContaining("nur für Plan freigegeben"),
+ },
+ ],
+ });
await expect(
test.plans.apply(
test.owner,
@@ -1499,6 +1514,43 @@ describe.skipIf(!enabled)(
expect(test.token).not.toHaveBeenCalled();
});
+ it("explains expected Apply denials while preserving eligibility for a valid saved plan", async () => {
+ const test = await fixture();
+ const saved = await test.plan();
+ const visible = () => test.plans.list(test.owner);
+ expect((await visible())[0]).toMatchObject({
+ applyAllowed: true,
+ applyBlocker: null,
+ });
+ test.profiles.verifyForPreparation.mockRejectedValueOnce(
+ new CredentialError(422, "access_check_failed"),
+ );
+ expect((await visible())[0]).toMatchObject({
+ applyAllowed: false,
+ applyBlocker: { code: "access_check_failed" },
+ });
+ await migration!.query(
+ "UPDATE lzc.plan_artifacts SET expires_at=now()-interval '1 minute' WHERE run_id=$1",
+ [saved.id],
+ );
+ expect((await visible())[0]).toMatchObject({
+ applyAllowed: false,
+ applyBlocker: { code: "plan_not_approvable" },
+ });
+ expect(test.runner.start).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not silently turn unexpected eligibility-check failures into a normal Apply denial", async () => {
+ const test = await fixture();
+ await test.plan();
+ const error = new Error(
+ "synthetic private eligibility dependency failure",
+ );
+ test.profiles.verifyForPreparation.mockRejectedValueOnce(error);
+ await expect(test.plans.list(test.owner)).rejects.toBe(error);
+ expect(test.runner.start).toHaveBeenCalledTimes(1);
+ });
+
it("requires exact hash, organization, current revision, credential and unexpired complete artifact before any apply dispatch", async () => {
for (const reason of [
"hash",
diff --git a/landing-zone-configurator/docs/plan-execution.md b/landing-zone-configurator/docs/plan-execution.md
index 3070a43..65bc36c 100644
--- a/landing-zone-configurator/docs/plan-execution.md
+++ b/landing-zone-configurator/docs/plan-execution.md
@@ -23,6 +23,22 @@ einen gated Apply-Endpunkt: nur bei aktivierter Execution, passendem unveränder
Planartefakt/Hash, bestätigter Zielorganisation und erneut geprüften Zugriffs-,
Quell- und State-Bindungen. Ein Export ist keine Apply-Freigabe. Kein Destroy-Pfad.
+Ein erfolgreicher Plan ist nicht automatisch fuer Apply freigegeben. Der Server
+liefert zu einer gesperrten Freigabe einen Fehlercode und bei nicht unterstuetzten
+Komponenten die betroffenen Felder mit Begruendung. Plan- und Apply-Seite zeigen
+diese Sperre an; der Apply-Button bleibt sichtbar, aber deaktiviert. Ein
+freigabefaehiger Plan zeigt weiterhin die explizite Pruefbestaetigung und verlangt
+die exakte UUID der Zielorganisation. Alle Bedingungen werden vor Apply erneut
+serverseitig geprueft.
+
+Firewall-/HA-Infrastruktur bleibt derzeit nur fuer Plan qualifiziert. Ein neuer
+Plan allein hebt diese Komponentensperre nicht auf. Der Administrations- und
+Bootstrap-Vertrag muss separat fuer Apply qualifiziert werden; Firewall-Policy
+und VPN-PSKs werden dabei nicht stillschweigend ausgelassen. Konfiguration und
+Plan-Nachweise bleiben unveraendert erhalten. Die komponentenweise
+Ausfuehrungsqualifikation wird in
+[#89](https://github.com/stackitcloud/stackit-landing-zone/issues/89) weitergefuehrt.
+
## Plattformentwürfe mit Projekt-Templates
Neue Editor-Konfigurationen enthalten Projekt-Template-Entwürfe statt konkreter