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 02db36c..bb8dca0 100644 --- a/landing-zone-configurator/app/apps/api/src/plans/service.ts +++ b/landing-zone-configurator/app/apps/api/src/plans/service.ts @@ -79,16 +79,26 @@ export class Plans { ); for (const row of rows) { row.applyAllowed = false; - if ( - !this.execution || - row.operation !== "plan" || - row.status !== "succeeded" || - !row.artifactSha256 - ) + row.applyBlocker = null; + if (row.operation !== "plan" || row.status !== "succeeded") continue; + if (!this.execution) { + row.applyBlocker = { code: "execution_disabled" }; + continue; + } + if (!row.artifactSha256) { + row.applyBlocker = { code: "artifact_invalid" }; continue; + } try { const manifest = await this.preparation(session, row.preparationId); - if (initialPlanIssues(manifest.configuration, "apply").length) continue; + const issues = initialPlanIssues(manifest.configuration, "apply"); + if (issues.length) { + row.applyBlocker = { + code: "configuration_apply_not_supported", + issues, + }; + continue; + } await this.verifySource(session, manifest); await this.verify(session, manifest); await withTenant(this.pool, session, async (client) => { @@ -114,8 +124,10 @@ export class Plans { this.execution!.bytes(session, artifact); }); row.applyAllowed = true; - } catch { + } catch (error) { + if (!(error instanceof CredentialError)) throw error; row.applyAllowed = false; + row.applyBlocker = { code: error.code }; } } return rows; 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 2903da8..015041c 100644 --- a/landing-zone-configurator/app/apps/web/src/components/PlanRuns.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/PlanRuns.tsx @@ -1,5 +1,6 @@ import { type PlanSummary, + type PlatformApplyBlocker, type PlatformCheckpoint, platformCheckpointSchema, type S3BackendDescriptor, @@ -22,6 +23,7 @@ type Run = { planId?: string; artifactSha256?: string; applyAllowed?: boolean; + applyBlocker?: PlatformApplyBlocker | null; expiresAt?: string | null; stateBackend?: S3BackendDescriptor | null; }; @@ -52,6 +54,16 @@ const labels: Record = { const failures: Record = { configuration_execution_not_supported: "Diese Konfiguration enthält Komponenten, die der Erstbereitstellungsplan noch nicht unterstützt. Wähle eine unterstützte Standalone-Konfiguration.", + configuration_apply_not_supported: + "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.", + configuration_changed: + "Die Konfiguration wurde seit der Vorbereitung geändert. Erstelle eine neue Vorbereitung und einen neuen Plan.", + configuration_not_found: + "Die gespeicherte Konfiguration ist nicht mehr verfügbar.", + github_connection_required: + "Verbinde GitHub erneut, damit die Quelle dieses Plans geprüft werden kann.", + credential_role_required: + "Deine aktuelle Rolle erlaubt keine Apply-Freigabe.", runner_unavailable: "Der Runner konnte nicht gestartet werden.", credential_changed: "Der Zugang wurde geändert. Erstelle eine neue Vorbereitung.", @@ -121,6 +133,31 @@ const failures: Record = { recovery_required: "Die Ausführung benötigt Wiederherstellung. Ressourcen können bereits verändert worden sein. Kein erneutes Apply starten.", }; + +function ApplyBlocker({ run }: { run: Run }) { + if (run.applyAllowed !== false || !run.applyBlocker) return null; + return ( +
+ {t("Apply derzeit gesperrt")} +

+ {t( + Object.hasOwn(failures, run.applyBlocker.code) + ? failures[run.applyBlocker.code] + : failures.apply_not_allowed, + )} +

+ {run.applyBlocker.issues && run.applyBlocker.issues.length > 0 && ( +
    + {run.applyBlocker.issues.map((issue) => ( +
  • + {issue.field}: {t(issue.message)} +
  • + ))} +
+ )} +
+ ); +} 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