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
28 changes: 20 additions & 8 deletions landing-zone-configurator/app/apps/api/src/plans/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
type PlanSummary,
type PlatformApplyBlocker,
type PlatformCheckpoint,
platformCheckpointSchema,
type S3BackendDescriptor,
Expand All @@ -22,6 +23,7 @@ type Run = {
planId?: string;
artifactSha256?: string;
applyAllowed?: boolean;
applyBlocker?: PlatformApplyBlocker | null;
expiresAt?: string | null;
stateBackend?: S3BackendDescriptor | null;
};
Expand Down Expand Up @@ -52,6 +54,16 @@ const labels: Record<string, string> = {
const failures: Record<string, string> = {
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.",
Expand Down Expand Up @@ -121,6 +133,31 @@ const failures: Record<string, string> = {
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 (
<div className="validation-box" role="status">
<strong>{t("Apply derzeit gesperrt")}</strong>
<p>
{t(
Object.hasOwn(failures, run.applyBlocker.code)
? failures[run.applyBlocker.code]
: failures.apply_not_allowed,
)}
</p>
{run.applyBlocker.issues && run.applyBlocker.issues.length > 0 && (
<ul>
{run.applyBlocker.issues.map((issue) => (
<li key={`${issue.field}:${issue.message}`}>
<code>{issue.field}</code>: {t(issue.message)}
</li>
))}
</ul>
)}
</div>
);
}
type Preparation = {
id: string;
name: string;
Expand Down Expand Up @@ -587,27 +624,35 @@ function PlanReview({
</p>
)}
{!allowed && (
<p className="field-hint">
{blocked
? t(
"Freigabe gesperrt. Prüfe den Ausführungsstatus und erstelle bei Bedarf einen neuen Plan.",
)
: Date.parse(sessionExpiresAt) <= Date.now()
<>
<ApplyBlocker run={run} />
<p className="field-hint">
{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.",
)}
</p>
: 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.",
)}
</p>
<button type="button" className="button primary" disabled>
{t("Freigegebenen Plan anwenden")}
</button>
</>
)}
{allowed && (
<>
Expand Down Expand Up @@ -1179,6 +1224,11 @@ export function PlanRuns({
)}
</p>
)}
{!readOnly &&
phase === "plan" &&
!historical &&
run.operation !== "apply" &&
run.status === "succeeded" && <ApplyBlocker run={run} />}
{!readOnly &&
phase === "apply" &&
!historical &&
Expand Down
13 changes: 13 additions & 0 deletions landing-zone-configurator/app/apps/web/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,19 @@ export const englishMessages: Record<string, string> = {
"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.":
Expand Down
92 changes: 89 additions & 3 deletions landing-zone-configurator/app/e2e/configurator.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFile } from "node:fs/promises";
import {
assistantProposalFields,
type PlatformApplyBlocker,
platformUpgradeAcceleratorCommit,
selectAssistantProposalFields,
} from "@lzc/contracts";
Expand Down Expand Up @@ -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 })
Expand All @@ -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,
Expand Down Expand Up @@ -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<typeof savedPlan> & {
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,
}) => {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,12 @@ export const appliedPlatformSourceSchema = z.strictObject({
});
export type AppliedPlatformSource = z.infer<typeof appliedPlatformSourceSchema>;

export type { ActionCounts, PlanAction, PlanSummary } from "./plan.js";
export type {
ActionCounts,
PlanAction,
PlanSummary,
PlatformApplyBlocker,
} from "./plan.js";
export {
applicationMvpAcceleratorCommit,
applicationPlanPreview,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading