From 1ed855609d4dcbfded6759c7c8b4fabf7bd6725d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 22 Oct 2026 08:45:41 +0000 Subject: [PATCH 1/3] fix(landing-zone): inherit current SNA prefix for automatic networks --- src/application/README.de.md | 9 ++ src/application/README.md | 8 ++ src/modules/landing-zone/3-network.tf | 9 +- src/modules/landing-zone/README.de.md | 20 +++- src/modules/landing-zone/README.md | 18 ++- .../landing-zone/tests/network.tftest.hcl | 113 +++++++++++++++++- 6 files changed, 169 insertions(+), 8 deletions(-) diff --git a/src/application/README.de.md b/src/application/README.de.md index 0c711072..4e424531 100644 --- a/src/application/README.de.md +++ b/src/application/README.de.md @@ -29,6 +29,15 @@ Observability-ACL-Quelle. Dieser Umfang muss bei Template-Veröffentlichung und sichtbar sein. Generierte Zugangsdaten verbleiben im geschützten Application-State; sie werden nicht über Root-Ausgaben offengelegt. +Bei Corporate-Projekten übernimmt `network_prefix_length = null` den aktuellen +Standard der SNA-Region durch eine lesende Provider-Abfrage während des Plans. +Explizite Präfixe und konfigurierte DNS-Server bleiben erhalten. Der verwendete +Zugang benötigt Leserechte für die SNA-Region. Ein geänderter SNA-Standard kann in +einem späteren Plan einen Austausch des Netzes bewirken; diese Änderung vor Apply +prüfen. Das funktioniert mit vorhandenen Plattformverträgen und benötigt weder +ein zusätzliches Vertragsfeld noch eine State-Migration. + + ## Unabhängiger zweiphasiger CLI-Ablauf diff --git a/src/application/README.md b/src/application/README.md index d0f2b5cf..77437947 100644 --- a/src/application/README.md +++ b/src/application/README.md @@ -27,6 +27,14 @@ qualify its private CIDR as an Observability ACL source. This scope must appear publication and plan review. Generated credentials remain in the protected application state; they are not exposed by the root outputs. +For Corporate projects, a null `network_prefix_length` inherits the current +SNA-region default through a read-only provider query during Plan. Explicit +prefixes are preserved and configured DNS is retained. The credential needs SNA +region read access. A changed SNA default may produce a network replacement in a +later Plan; review that change before Apply. This works with existing platform +contracts and does not require a new contract field or State migration. + + ## Independent two-phase CLI workflow ### Phase 1: Platform diff --git a/src/modules/landing-zone/3-network.tf b/src/modules/landing-zone/3-network.tf index cbbb251b..67769e3d 100644 --- a/src/modules/landing-zone/3-network.tf +++ b/src/modules/landing-zone/3-network.tf @@ -35,12 +35,19 @@ resource "stackit_routing_table_route" "this" { ############# ## NETWORK ## ############# +data "stackit_network_area_region" "defaults" { + count = var.corporate && var.network_prefix_length == null ? 1 : 0 + + organization_id = var.organization_id + network_area_id = var.network_area_id +} + resource "stackit_network" "this" { count = var.corporate || var.network_enabled ? 1 : 0 name = "${var.naming_pattern}-${var.corporate ? "routed" : "local"}" project_id = stackit_resourcemanager_project.this.project_id - ipv4_prefix_length = var.network_prefix_length + ipv4_prefix_length = var.corporate && var.network_prefix_length == null ? data.stackit_network_area_region.defaults[0].ipv4.default_prefix_length : var.network_prefix_length routed = var.corporate ipv4_nameservers = var.ipv4_nameservers routing_table_id = var.corporate && var.firewall_next_hop_ip != null ? stackit_routing_table.this[0].routing_table_id : null diff --git a/src/modules/landing-zone/README.de.md b/src/modules/landing-zone/README.de.md index 2c246dea..f7098ea7 100644 --- a/src/modules/landing-zone/README.de.md +++ b/src/modules/landing-zone/README.de.md @@ -6,9 +6,22 @@ Corporate-Projekte behalten ihr geroutetes Netzwerk und ihre SNA-Integration. Wird die Option weggelassen, bleiben bestehende öffentliche Projekte ohne Netzwerk unverändert. Das optionale `network_prefix_length` gilt für beide Arten von Projektnetzwerken. +Bei Corporate-Projekten liest `null` über den regionalen Provider des Moduls die +aktuelle Standardpräfixlänge der ausgewählten SNA-Region und verwendet diese +effektive Netzgröße beim Planen. Dafür ist die Berechtigung zum Lesen der +Netzbereichsregion erforderlich. Explizite Größen wie `/26` bleiben unverändert +und benötigen diese Abfrage nicht. DNS-Einstellungen bleiben wie angegeben; +automatische Größenwahl entfernt keine konfigurierten Resolver. Public-Netze +behalten die automatische Größenwahl des Providers und fragen keine SNA ab. + +Automatische Größenwahl weist keine freie Kapazität im Adresspool nach. Ändert +sich der SNA-Standard, kann ein späterer Plan eine Größenänderung oder einen +Austausch eines vorhandenen Projektnetzes zeigen. Den Plan vor der ausdrücklichen +Apply-Freigabe prüfen; State-Migrationen oder Änderungen an Ressourcenadressen +sind dafür nicht erforderlich. Der folgende generatorverwaltete technische Block bleibt unverändert auf Englisch; -maßgeblich ist die [kanonische englische Referenz](README.de.md). +maßgeblich ist die [kanonische englische Referenz](README.md). ## Requirements @@ -23,8 +36,8 @@ maßgeblich ist die [kanonische englische Referenz](README.de.md). | Name | Version | | ---- | ------- | -| [stackit](#provider\_stackit) | 0.114.0 | -| [time](#provider\_time) | 0.14.1 | +| [stackit](#provider\_stackit) | >= 0.114.0 | +| [time](#provider\_time) | >= 0.14.1 | ## Modules @@ -51,6 +64,7 @@ No modules. | [stackit_service_account.automation](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/service_account) | resource | | [stackit_service_account_key.automation](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/service_account_key) | resource | | [time_rotating.key_rotate](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/rotating) | resource | +| [stackit_network_area_region.defaults](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/data-sources/network_area_region) | data source | ## Inputs diff --git a/src/modules/landing-zone/README.md b/src/modules/landing-zone/README.md index 9e086410..f8c2be3a 100644 --- a/src/modules/landing-zone/README.md +++ b/src/modules/landing-zone/README.md @@ -5,7 +5,18 @@ Public projects can opt into a local project network with `network_enabled = true`. Corporate projects retain their routed network and SNA integration. Omitting the option preserves existing public projects without a network. The optional -`network_prefix_length` applies to both kinds of project network. +`network_prefix_length` applies to both kinds of project network. For Corporate +projects, `null` reads the selected SNA region's current default prefix length +through the module's regional provider and uses that effective size when planning +the network. This read requires permission to view the network area region. +Explicit sizes, such as `/26`, bypass the read and remain unchanged. DNS settings +remain as supplied; automatic sizing does not remove configured resolvers. Public +networks keep provider-managed automatic sizing and do not query an SNA. + +Automatic sizing does not prove address-pool capacity. If an SNA default changes, +a later Plan can show a size change or replacement for an existing project +network. Review the Plan before explicitly approving Apply; no State migration +or resource-address changes are required. ## Requirements @@ -20,8 +31,8 @@ option preserves existing public projects without a network. The optional | Name | Version | | ---- | ------- | -| [stackit](#provider\_stackit) | 0.114.0 | -| [time](#provider\_time) | 0.14.1 | +| [stackit](#provider\_stackit) | >= 0.114.0 | +| [time](#provider\_time) | >= 0.14.1 | ## Modules @@ -48,6 +59,7 @@ No modules. | [stackit_service_account.automation](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/service_account) | resource | | [stackit_service_account_key.automation](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/service_account_key) | resource | | [time_rotating.key_rotate](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/rotating) | resource | +| [stackit_network_area_region.defaults](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/data-sources/network_area_region) | data source | ## Inputs diff --git a/src/modules/landing-zone/tests/network.tftest.hcl b/src/modules/landing-zone/tests/network.tftest.hcl index 07cc3bb2..b7cc37e8 100644 --- a/src/modules/landing-zone/tests/network.tftest.hcl +++ b/src/modules/landing-zone/tests/network.tftest.hcl @@ -1,4 +1,35 @@ mock_provider "stackit" { + mock_data "stackit_network_area_region" { + defaults = { + ipv4 = { + min_prefix_length = 24 + max_prefix_length = 28 + transfer_network = "10.254.255.0/24" + network_ranges = [{ network_range_id = "77777777-2222-4333-8444-555555555555", prefix = "10.32.0.0/16" }] + default_prefix_length = 28 + default_nameservers = ["192.0.2.54"] + } + } + } + + mock_resource "stackit_resourcemanager_project" { + defaults = { + project_id = "44444444-2222-4333-8444-555555555555" + container_id = "55555555-2222-4333-8444-555555555555" + } + } + + mock_resource "stackit_objectstorage_credentials_group" { + defaults = { credentials_group_id = "66666666-2222-4333-8444-555555555555" } + } + + mock_resource "stackit_service_account" { + defaults = { email = "automation@sa.stackit.cloud" } + } +} +mock_provider "stackit" { + alias = "eu02" + mock_resource "stackit_resourcemanager_project" { defaults = { project_id = "44444444-2222-4333-8444-555555555555" @@ -13,6 +44,19 @@ mock_provider "stackit" { mock_resource "stackit_service_account" { defaults = { email = "automation@sa.stackit.cloud" } } + + mock_data "stackit_network_area_region" { + defaults = { + ipv4 = { + min_prefix_length = 24 + max_prefix_length = 28 + transfer_network = "10.254.255.0/24" + network_ranges = [{ network_range_id = "77777777-2222-4333-8444-555555555555", prefix = "10.32.0.0/16" }] + default_prefix_length = 27 + default_nameservers = ["192.0.2.55"] + } + } + } } mock_provider "time" {} @@ -33,7 +77,7 @@ run "legacy_public_without_network" { } assert { - condition = length(stackit_network.this) == 0 + condition = length(stackit_network.this) == 0 && length(data.stackit_network_area_region.defaults) == 0 error_message = "Existing public projects must not gain a network implicitly." } } @@ -72,6 +116,73 @@ run "legacy_corporate_network" { } } +run "corporate_automatic_prefix_preserves_dns" { + command = plan + + variables { + corporate = true + network_area_id = "33333333-2222-4333-8444-555555555555" + ipv4_nameservers = ["192.0.2.53"] + } + + assert { + condition = length(data.stackit_network_area_region.defaults) == 1 && data.stackit_network_area_region.defaults[0].network_area_id == var.network_area_id && data.stackit_network_area_region.defaults[0].organization_id == var.organization_id && stackit_network.this[0].ipv4_prefix_length == 28 + error_message = "Automatic Corporate size must inherit the selected SNA region's actual /28 default, not an arbitrary /24." + } + + assert { + condition = stackit_network.this[0].ipv4_nameservers == tolist(["192.0.2.53"]) && stackit_network.this[0].routed + error_message = "Reading the network area's size must preserve the bound target's DNS and routed semantics." + } +} + +run "corporate_explicit_prefix_skips_sna_read" { + command = plan + + variables { + corporate = true + network_area_id = "33333333-2222-4333-8444-555555555555" + network_prefix_length = 26 + ipv4_nameservers = [] + } + + assert { + condition = length(data.stackit_network_area_region.defaults) == 0 && stackit_network.this[0].ipv4_prefix_length == 26 && length(stackit_network.this[0].ipv4_nameservers) == 0 + error_message = "An explicit /26 and an intentionally empty DNS list must survive without querying the SNA default." + } +} + +run "public_automatic_skips_sna_read" { + command = plan + + variables { + network_enabled = true + } + + assert { + condition = length(data.stackit_network_area_region.defaults) == 0 && !stackit_network.this[0].routed + error_message = "Automatic Public networking must not query a Corporate SNA." + } +} + +run "corporate_automatic_uses_inherited_regional_provider" { + command = plan + + providers = { + stackit = stackit.eu02 + } + + variables { + corporate = true + network_area_id = "33333333-2222-4333-8444-555555555555" + } + + assert { + condition = stackit_network.this[0].ipv4_prefix_length == 27 && data.stackit_network_area_region.defaults[0].ipv4.default_prefix_length == 27 + error_message = "The SNA default must come from the module's regional provider, not a hard-coded eu01 provider." + } +} + run "observability_acl_existing_value" { command = apply From fd24ca3c00f67aaa3ddfa4ccd431bb09f8331756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 22 Oct 2026 09:22:38 +0000 Subject: [PATCH 2/3] fix(configurator): support automatic Corporate sizing and complete order identity --- landing-zone-configurator/app/README.de.md | 8 + landing-zone-configurator/app/README.md | 8 + ...4_application_automatic_network_source.sql | 22 ++ ..._application_order_identity_completion.sql | 82 ++++++ .../app/apps/api/src/applications/service.ts | 44 +++- .../assistant/knowledge-topics.generated.ts | 26 +- .../apps/api/src/assistant/model-serving.ts | 13 +- .../apps/web/src/components/Applications.tsx | 5 +- .../web/src/components/ProjectTemplates.tsx | 2 +- .../app/apps/web/src/locales/en.ts | 10 +- .../app/e2e/organisation.spec.ts | 203 +++++++++++++++ .../app/integration/identity.test.ts | 237 ++++++++++++++++++ .../app/packages/contracts/src/plan-output.ts | 10 + .../app/packages/contracts/src/plan.ts | 3 +- .../domain/src/application-catalogue.ts | 2 + .../app/scripts/assistant-scenarios.ts | 142 ++++++++++- .../prepare-application-project-contract.ts | 46 +++- .../app/scripts/test-application-contract.sh | 4 + .../app/scripts/test-application-network.sh | 105 ++++++++ .../app/tests/assistant-scenarios.test.ts | 126 +++++++++- .../app/tests/platform-worker.test.ts | 10 +- .../app/tests/runner-progress.test.ts | 25 ++ .../deploy/runner/package.sh | 2 +- .../docs/assistant-feature-coverage.json | 2 +- .../docs/assistant-knowledge/manifest.json | 12 +- .../project-templates.de.md | 37 ++- .../assistant-knowledge/project-templates.md | 65 ++--- .../docs/assistant-test-catalogue.json | 46 +++- .../docs/user-guide.de.md | 41 +++ landing-zone-configurator/docs/user-guide.md | 33 +++ 30 files changed, 1296 insertions(+), 75 deletions(-) create mode 100644 landing-zone-configurator/app/apps/api/db/064_application_automatic_network_source.sql create mode 100644 landing-zone-configurator/app/apps/api/db/065_application_order_identity_completion.sql create mode 100644 landing-zone-configurator/app/scripts/test-application-network.sh diff --git a/landing-zone-configurator/app/README.de.md b/landing-zone-configurator/app/README.de.md index 8d4f1eb6..26315066 100644 --- a/landing-zone-configurator/app/README.de.md +++ b/landing-zone-configurator/app/README.de.md @@ -19,6 +19,14 @@ npm run check `check` führt Biome, TypeScript-Prüfung einschließlich Tests, Produktionsbuild und Vitest aus. `npm run format` korrigiert Formatierung und Importreihenfolge. +`npm run test:plan` prüft zusätzlich den echten gepinnten STACKIT-Netzwerkprovider +über `scripts/test-application-network.sh`. Die Prüfung verwendet frischen +temporären lokalen State, eine bereinigte Umgebung, Dummy-Authentifizierung und +einen Loopback-IaaS-Endpunkt. Sie deckt fehlende Präfixe mit gefüllter/leerer +DNS-Liste, SNA-Standard `/28`, explizite `/24` und `/26`, verzögerte Präfixauflösung +sowie automatische Größe ohne konfigurierte DNS-Server ab. Gewöhnliche Provider-Mocks umgehen diese Validierung; es gibt +keinen Apply und keinen Zugriff auf eine Kunden-Cloud. + Falls die passende Runtime lokal noch fehlt, funktioniert ohne globale Installation beispielsweise: ```sh diff --git a/landing-zone-configurator/app/README.md b/landing-zone-configurator/app/README.md index f7e57525..f0663be3 100644 --- a/landing-zone-configurator/app/README.md +++ b/landing-zone-configurator/app/README.md @@ -19,6 +19,14 @@ npm run check `check` runs Biome, TypeScript checking including tests, the production build and Vitest. `npm run format` corrects formatting and import order. +`npm run test:plan` additionally exercises the real pinned STACKIT network +provider through `scripts/test-application-network.sh`. It uses fresh temporary +local state, a sanitized environment, dummy authentication and a loopback IaaS +endpoint. The cases cover missing prefixes with populated/empty DNS lists, +SNA-default `/28`, explicit `/24` and `/26`, deferred prefix resolution, and +automatic sizing without configured DNS. Ordinary +provider mocks bypass this validation; no Apply or customer cloud access is used. + If the matching runtime is not yet available locally, the following works without a global installation, for example: ```sh diff --git a/landing-zone-configurator/app/apps/api/db/064_application_automatic_network_source.sql b/landing-zone-configurator/app/apps/api/db/064_application_automatic_network_source.sql new file mode 100644 index 00000000..a42f4ffd --- /dev/null +++ b/landing-zone-configurator/app/apps/api/db/064_application_automatic_network_source.sql @@ -0,0 +1,22 @@ +-- Allow the reviewed Corporate automatic-prefix source for new jobs. +-- Existing template versions, jobs, State keys and backend delegations remain unchanged. +ALTER TABLE lzc.application_job_grants DROP CONSTRAINT application_job_grants_accelerator_revision_check; +ALTER TABLE lzc.application_job_grants ADD CONSTRAINT application_job_grants_accelerator_revision_check + CHECK(accelerator_revision IN ('4d15d7870afa323badd93559d8b37c5a8d138dcf','c4b43c36af198985980b17626c48d357795e3fbd','88149782bf8e91dcdbb43a203b54337886023f7f','57ad1f6a651c1787694b74ff8aa8b241a3dcd16f','9e7fb46c329aca32e878ce1f0ea3b944ea240354','1ed855609d4dcbfded6759c7c8b4fabf7bd6725d')); +ALTER TABLE lzc.application_runner_tickets DROP CONSTRAINT application_runner_tickets_accelerator_revision_check; +ALTER TABLE lzc.application_runner_tickets ADD CONSTRAINT application_runner_tickets_accelerator_revision_check + CHECK(accelerator_revision IN ('c4b43c36af198985980b17626c48d357795e3fbd','88149782bf8e91dcdbb43a203b54337886023f7f','57ad1f6a651c1787694b74ff8aa8b241a3dcd16f','9e7fb46c329aca32e878ce1f0ea3b944ea240354','1ed855609d4dcbfded6759c7c8b4fabf7bd6725d')); + +DO $$ +DECLARE signature text; definition text; old_source text := '''9e7fb46c329aca32e878ce1f0ea3b944ea240354'''; +BEGIN + FOREACH signature IN ARRAY ARRAY[ + 'lzc.issue_application_job_grant()', + 'lzc_auth.reserve_application_dispatch(uuid,uuid)', + 'lzc_auth.delegated_application_plan_operation(uuid,uuid,text,jsonb)' + ] LOOP + definition := pg_get_functiondef(signature::regprocedure); + IF strpos(definition,old_source)=0 THEN RAISE EXCEPTION 'application_source_guard_missing'; END IF; + EXECUTE replace(definition,old_source,old_source || ',''1ed855609d4dcbfded6759c7c8b4fabf7bd6725d'''); + END LOOP; +END $$; diff --git a/landing-zone-configurator/app/apps/api/db/065_application_order_identity_completion.sql b/landing-zone-configurator/app/apps/api/db/065_application_order_identity_completion.sql new file mode 100644 index 00000000..a3d1c612 --- /dev/null +++ b/landing-zone-configurator/app/apps/api/db/065_application_order_identity_completion.sql @@ -0,0 +1,82 @@ +-- Complete an order created before its owner's first valid human proof. Never +-- rebind a known identity or change approved/executed order evidence. +CREATE TABLE lzc.application_order_identity_completions ( + instance_id uuid PRIMARY KEY, + tenant_id uuid NOT NULL REFERENCES lzc.tenants(id), + completed_by uuid NOT NULL REFERENCES lzc_auth.users(id), + issuer text NOT NULL, + subject text NOT NULL, + email text NOT NULL, + initial_settings jsonb NOT NULL, + completed_settings jsonb NOT NULL, + initial_blockers jsonb NOT NULL, + completed_blockers jsonb NOT NULL, + completed_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY(tenant_id,instance_id) REFERENCES lzc.application_instances(tenant_id,id) +); +ALTER TABLE lzc.application_order_identity_completions ENABLE ROW LEVEL SECURITY; +ALTER TABLE lzc.application_order_identity_completions FORCE ROW LEVEL SECURITY; +CREATE POLICY application_identity_completion_read ON lzc.application_order_identity_completions FOR SELECT TO configurator_app + USING(tenant_id=lzc.current_tenant_id() AND ((completed_by=lzc.current_user_id() AND lzc.application_role('application-owner')) OR lzc.application_role('platform-engineer'))); +CREATE POLICY application_identity_completion_migration ON lzc.application_order_identity_completions TO configurator_migration USING(true) WITH CHECK(true); +GRANT SELECT ON lzc.application_order_identity_completions TO configurator_app; +CREATE TRIGGER immutable_application_identity_completion BEFORE UPDATE OR DELETE ON lzc.application_order_identity_completions + FOR EACH ROW EXECUTE FUNCTION lzc.immutable_application_version(); + +CREATE FUNCTION lzc_auth.complete_application_order_identity(p_session uuid,p_instance uuid,p_settings jsonb) +RETURNS lzc.application_instances LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE ordered lzc.application_instances; human lzc.stackit_identities; published lzc.application_template_versions; + expected_settings jsonb; expected_roles jsonb; added_roles jsonb; role_policy jsonb; blockers jsonb; +BEGIN + PERFORM lzc_auth.authorize_application(p_session,lzc.current_tenant_id(),'order'); + -- Decision/deletion operations take this same row lock. Existing jobs are an + -- absolute boundary; their inputs, grants, artifacts and State never get rebound. + SELECT * INTO ordered FROM lzc.application_instances + WHERE id=p_instance AND tenant_id=lzc.current_tenant_id() AND requested_by=lzc.current_user_id() FOR UPDATE; + IF NOT FOUND OR EXISTS(SELECT 1 FROM lzc.application_order_deletions WHERE instance_id=p_instance) THEN + RAISE EXCEPTION 'application_identity_completion_access_denied' USING ERRCODE='42501'; + END IF; + SELECT * INTO human FROM lzc.stackit_identities + WHERE user_id=ordered.requested_by AND issuer='https://accounts.stackit.cloud' AND revoked_at IS NULL AND valid_until>now() FOR SHARE; + IF NOT FOUND THEN RAISE EXCEPTION 'application_identity_completion_access_denied' USING ERRCODE='42501'; END IF; + IF ordered.resolved_settings->>'owner_email' IS NOT NULL THEN + IF ordered.resolved_settings->>'owner_email' IS DISTINCT FROM human.email THEN + RAISE EXCEPTION 'application_owner_identity_changed' USING ERRCODE='40001'; + END IF; + RETURN ordered; + END IF; + IF EXISTS(SELECT 1 FROM lzc.application_jobs WHERE instance_id=p_instance) OR + EXISTS(SELECT 1 FROM lzc.application_order_decisions WHERE instance_id=p_instance) OR + EXISTS(SELECT 1 FROM lzc.application_order_identity_completions WHERE instance_id=p_instance) THEN + RAISE EXCEPTION 'application_order_identity_completion_unavailable' USING ERRCODE='40001'; + END IF; + SELECT * INTO published FROM lzc.application_template_versions WHERE id=ordered.version_id AND tenant_id=ordered.tenant_id; + IF NOT FOUND OR NOT lzc.application_template_access(ordered.version_id) THEN RAISE EXCEPTION 'application_identity_completion_access_denied' USING ERRCODE='42501'; END IF; + expected_settings:=ordered.resolved_settings || jsonb_build_object('owner_email',human.email); + role_policy:=published.document#>'{parameterPolicy,fields,role_assignments}'; + IF role_policy->>'source'='context' AND role_policy->>'variable'='verified-project-owner' THEN + expected_roles:=coalesce(ordered.resolved_settings->'role_assignments','[]'::jsonb); + SELECT coalesce(jsonb_agg(jsonb_build_object('role',role.value,'subject',human.email) ORDER BY role.position),'[]'::jsonb) + INTO added_roles FROM jsonb_array_elements_text(role_policy->'roles') WITH ORDINALITY AS role(value,position) + WHERE NOT EXISTS(SELECT 1 FROM jsonb_array_elements(expected_roles) assignment WHERE assignment->>'role'=role.value AND assignment->>'subject'=human.email); + expected_settings:=jsonb_set(expected_settings,'{role_assignments}',expected_roles || added_roles); + END IF; + -- The normal domain resolver supplies the candidate. SQL independently limits + -- completion to this verified email and the template's declared context roles. + IF p_settings IS DISTINCT FROM expected_settings THEN + RAISE EXCEPTION 'application_identity_completion_access_denied' USING ERRCODE='42501'; + END IF; + SELECT coalesce(jsonb_agg(item.value ORDER BY item.position),'[]'::jsonb) INTO blockers + FROM jsonb_array_elements(ordered.qualification_blockers) WITH ORDINALITY item(value,position) + WHERE item.value NOT IN ( + '"Verifizierte STACKIT-Benutzeridentität für diese Bestellung fehlt."'::jsonb, + '"Die STACKIT-Identität der projektverantwortlichen Person wird erst bei der Instanziierung verifiziert."'::jsonb + ); + INSERT INTO lzc.application_order_identity_completions(instance_id,tenant_id,completed_by,issuer,subject,email,initial_settings,completed_settings,initial_blockers,completed_blockers) + VALUES(ordered.id,ordered.tenant_id,ordered.requested_by,human.issuer,human.subject,human.email,ordered.resolved_settings,expected_settings,ordered.qualification_blockers,blockers); + UPDATE lzc.application_instances SET resolved_settings=expected_settings,qualification_blockers=blockers + WHERE id=ordered.id AND tenant_id=ordered.tenant_id RETURNING * INTO ordered; + RETURN ordered; +END $$; +REVOKE ALL ON FUNCTION lzc_auth.complete_application_order_identity(uuid,uuid,jsonb) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION lzc_auth.complete_application_order_identity(uuid,uuid,jsonb) TO configurator_app; diff --git a/landing-zone-configurator/app/apps/api/src/applications/service.ts b/landing-zone-configurator/app/apps/api/src/applications/service.ts index ed458ebd..2400ce62 100644 --- a/landing-zone-configurator/app/apps/api/src/applications/service.ts +++ b/landing-zone-configurator/app/apps/api/src/applications/service.ts @@ -62,6 +62,7 @@ export const applicationRunnerPackageSchema = z.strictObject({ "88149782bf8e91dcdbb43a203b54337886023f7f", "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f", "9e7fb46c329aca32e878ce1f0ea3b944ea240354", + "1ed855609d4dcbfded6759c7c8b4fabf7bd6725d", ]), providerLockSha256: z.literal( "d40debbff204aee590c2a76d09f6ad3234643329b438fd5c6497de60687f6fa5", @@ -1654,6 +1655,7 @@ export class Applications { "88149782bf8e91dcdbb43a203b54337886023f7f", "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f", "9e7fb46c329aca32e878ce1f0ea3b944ea240354", + "1ed855609d4dcbfded6759c7c8b4fabf7bd6725d", ].includes(prepared.plan.acceleratorRevision) ) throw new ApplicationError(409, "application_runner_revision_required"); @@ -2446,6 +2448,7 @@ export class Applications { "88149782bf8e91dcdbb43a203b54337886023f7f", "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f", "9e7fb46c329aca32e878ce1f0ea3b944ea240354", + "1ed855609d4dcbfded6759c7c8b4fabf7bd6725d", ]).parse(acceleratorRevision); if (!this.profiles || !this.secrets) throw new ApplicationError( @@ -2550,7 +2553,7 @@ export class Applications { "SELECT i.* FROM lzc.application_instances i WHERE i.id=$1 AND i.requested_by=$2 AND NOT EXISTS(SELECT 1 FROM lzc.application_order_deletions deleted WHERE deleted.instance_id=i.id AND deleted.tenant_id=i.tenant_id)", [id, session.userId], ); - const row = instances.rows[0]; + let row = instances.rows[0]; if (!row) throw new ApplicationError(404, "application_instance_not_found"); const versions = await client.query( "SELECT * FROM lzc.application_template_versions WHERE id=$1", @@ -2573,7 +2576,10 @@ export class Applications { ).rows[0]; if (!identity) throw new ApplicationError(403, "verified_application_identity_required"); - if (row.resolved_settings.owner_email !== identity.email) + if ( + row.resolved_settings.owner_email != null && + row.resolved_settings.owner_email !== identity.email + ) throw new ApplicationError(409, "application_owner_identity_changed"); const contracts = await client.query( "SELECT * FROM lzc.application_platform_contracts WHERE revision=$1", @@ -2593,6 +2599,40 @@ export class Applications { }, { verifiedStackitEmail: identity.email }, ); + if (row.resolved_settings.owner_email == null) { + try { + const completed = await client.query( + "SELECT completed.* FROM lzc_auth.complete_application_order_identity($1,$2,$3::jsonb) completed", + [ + session.id, + row.id, + JSON.stringify({ + ...resolved.resolution.settings, + owner_email: identity.email, + }), + ], + ); + const completedRow = completed.rows[0]; + if (!completedRow) + throw new ApplicationError( + 409, + "application_order_identity_completion_unavailable", + ); + row = completedRow; + } catch (cause) { + if ( + cause instanceof Error && + "code" in cause && + cause.code === "40001" && + [ + "application_owner_identity_changed", + "application_order_identity_completion_unavailable", + ].includes(cause.message) + ) + throw new ApplicationError(409, cause.message); + throw cause; + } + } if (resolved.resolution.qualificationBlockers.length) throw new ApplicationError(409, "application_parameters_not_qualified"); const settings = resolved.resolution.settings; diff --git a/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts b/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts index 48a23a1f..c52d1c4f 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts @@ -407,7 +407,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts", sha256: - "630dd873cfcff63dadc258c41ccc0a63a654059a5d9347f2ef86c66438aed327", + "fbdc3346807430f05b408568f5db8cf8da3937254c456d8eb1ac3f920cda3b22", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -1276,8 +1276,8 @@ export const assistantKnowledgeTopics = { { id: "project-templates", file: "project-templates.md", - version: 1, - checkedOn: "2026-10-10", + version: 2, + checkedOn: "2026-10-22", qualification: "local-draft-not-reviewed-release", groups: ["projects"], mandatory: false, @@ -1318,13 +1318,25 @@ export const assistantKnowledgeTopics = { sha256: "27b7edb026926aef1b6efb4f8e2c131d43a37afb2abaa78f0d600006a5f4a492", }, + { + path: "src/modules/landing-zone/3-network.tf", + sha256: + "0f37a5a088f8555a2c3019d63c2a1b4087aab032bd2fb455d391726deb953471", + }, + { + path: "landing-zone-configurator/app/packages/domain/src/application-plan.ts", + sha256: + "748832e7c2284bba61b11f194164c8f513225010d18571f283ee7b72723bf302", + }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", sha256: "b6c19e75d733b369016e33c911526aa1a163affd514e15e8d36c394965b0fcb1", }, ], - externalSources: [], + externalSources: [ + "https://github.com/stackitcloud/terraform-provider-stackit/blob/v0.114.0/stackit/internal/services/iaas/network/resource.go", + ], scenarios: ["A04", "A10", "A14", "A22", "A23", "A24"], fieldPaths: [ "landing_zones", @@ -1358,9 +1370,9 @@ export const assistantKnowledgeTopics = { ], source: "landing-zone-configurator/docs/assistant-knowledge/project-templates.md", - text: '# Landing-Zone Projects and Application Templates\n\n## Purpose\n\nReusable Public/Corporate project foundations, not application provisioning.\n\n## Accelerator Contract\n\nNative landing_zones supports corporate, region, network_area_key, project_code,\nenv, owner_email, network_enabled/network_prefix_length, role_assignments,\ncustom_roles, observability and secretsmanager_enabled. Public instances explicitly\nset corporate=false. Platform drafts use projectTemplates and parameter policies,\nnot personal instance fields or invented publication/order Terraform attributes.\n\n## Dependencies\n\nGovernance/identity, same-region SNA for Corporate and separate credentials.\nPublic is not a Corporate spoke. DNS, Secrets Manager and observability are separate.\n\n## Dialogue\n\nTemplate setup is draft, including a switch after Audit/Git. New entries require\nname, kind, region and settings; existing entries support partial updates with\nstable keys/identities retained. Delegated naming means choose business names,\nnot another naming question. Central-network versus Internet-only means Corporate\nversus Public; requested local networks set network_enabled=true for both.\nReuse saved region, unambiguous same-region area and native subnet defaults.\nMerge new templates; omit unrequested services, not inherited platform Audit/Git.\nAsk about missing/ambiguous choices and requested application-owner policies.\nNo sandboxes means no sandbox creation, not skipping Public/Corporate setup.\nDo not remove unrelated templates without consent. "100 projects" is capacity,\nnot 100 generated examples or a template quota. Compute from effective prefixes\nand reserve platform/firewall/future ranges.\n\n## Examples\n\nPublic instances use corporate=false. network_prefix_length=24 overrides an SNA\n/25 default. These are fragments, not automatic project creation requests.\n\n## Limits\n\nReviewed native settings/policies can be proposed, not published, ordered or\nprovisioned. Region, DNS/area and namespace references must match. Empty roles/ACLs\nare not least-privilege proof. Quotas/usable addresses need live checks; theoretical\nsubnet counts are not guarantees.\n\n## Credentials\n\nProvisioning, delegated-owner credentials, workload access and runtime grants are\nseparate. No secrets/profile IDs in model context or exports; external execution\nneeds explicit bindings.\n\n## Sources\n\nRoot/landing-zone variables and common template/compiler/validation contracts.\nRe-review policy/identity/export changes; application lifecycle remains separate.', + text: '# Landing-Zone Projects and Application Templates\n\n## Purpose\n\nReusable Public/Corporate foundations, not provisioning.\n\n## Accelerator Contract\n\nlanding_zones: corporate, region, area, project_code/env/owner_email, network\nenabled/prefix, roles, observability, secretsmanager. Public: corporate=false.\nPlatform drafts use projectTemplates/policies, not personal instance fields\nor invented Terraform publication/order attributes.\nAutomatic keeps network_prefix_length=null. Corrected Corporate module reads\nthe attached regional SNA default_prefix_length during Plan and supplies it to\nthe provider, retaining DNS. Explicit prefixes override it. Public automatic\nsizing is provider-managed, without a Corporate SNA read.\nProvider0.114.0: null prefix+DNS (even []) fails Plan (not necessarily validate):\n"Invalid IPv4 configuration". Fix: resolve effective prefix.\n\n## Dependencies\n\nGovernance/identity, same-region SNA for Corporate, separate credentials.\nPublic is no Corporate spoke; services are separate.\n\n## Dialogue\n\nDraft setup, also after Audit/Git: new entries need name/kind/region/settings;\nupdates retain identities. Delegated naming means choose names.\nCentral versus Internet-only means Corporate versus Public; local networks set\nnetwork_enabled=true. Reuse saved region/unambiguous area and existing prefixes.\nMerge additions; omit unrequested services, retain platform Audit/Git.\nAsk missing choices/policies. No sandboxes does not skip projects; removals need consent.\n"100 projects" is capacity, not generated examples/quota. Calculate effective\nprefixes with platform/firewall/future reservations; no free-capacity guarantee.\nAutomatic means inherit actual SNA default, never fixed /24 or disabled DNS.\nDraft defaults are not live evidence. Do not demand numbers for Automatic.\nExplicitly requested /26 proposes settings.network_prefix_length=26; switching\nback to Automatic proposes null. Preserve other settings/identity. Ask capacity/pool constraints for undecided overrides only.\n\n## Examples\n\nSNA default /28 + Automatic: null in template, effective /28 to provider, DNS\nretained. Confirmed /26 overrides it. /24 is an example, not universally suitable.\n\n## Limits\n\nPropose only; never publish/order/Plan/Apply. Native fix needs corrected Runner\nrevision/new version/order; old immutable versions retain source/settings. Never\ndelete State. Region/DNS/area/namespace must match; empty roles/ACLs prove no security.\nQuotas/usable addresses need live checks; subnet arithmetic is no guarantee.\n\n## Credentials\n\nSeparate access/runtime grants. No secrets/profile IDs in context/exports;\nexecution needs explicit external bindings.\n\n## Sources\n\nNative wiring, template/compiler contracts, pinned IPv4 validator; re-review changes.\n', sha256: - "2cb67b09875f5acbde78dc62452e193868dbad48774172ee4b8271261a5cac71", + "db083f2d9fd66fe514df5d92b202d64a8cf890db78d32d5b36ac98c60319f4e7", }, { id: "sandbox-projects", @@ -1545,7 +1557,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts", sha256: - "630dd873cfcff63dadc258c41ccc0a63a654059a5d9347f2ef86c66438aed327", + "fbdc3346807430f05b408568f5db8cf8da3937254c456d8eb1ac3f920cda3b22", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/knowledge.ts", diff --git a/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts b/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts index eda0c85d..24e49cf8 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts @@ -1351,7 +1351,7 @@ Choose at most three clear business questions when essential information is miss Operations: merge recursively changes only supplied object/map attributes; arrays and null are atomic replacements. replace replaces the COMPLETE value at that path. For projectTemplates and DNS zone collections each changed/removed entry is separately reviewable. A USER-defined desired complete set is replacement intent even without the word delete; explicitly additional entries are merge, corrections update existing stable keys. Follow the source-grounded templateSetup.collectionMode and dnsCollection, never a blanket keep/delete-examples rule. Ask when intent is ambiguous. default removes an explicit optional override/entry and restores the native default, value=null. Never use replace/default without user intent. Never reorder lists unless requested. Each path appears only once; never emit overlapping parent/child paths. Do not repeat unchanged settings. Use an existing nested path to reset/delete just that setting, preserving unrelated components. New collection entries use their parent path and a complete native value with existing stable keys retained. Audit creation needs explicit retention_days, ACL CIDRs and s3_object_lock together; ask for missing decisions, do not activate a public ACL or irreversible Object Lock silently. Offer explained Object Lock yes/no options; native default is true but contradictory documentation is not consent. Retention with lock must be 1-365 days. For organizational/default scope OMIT link_scopes or use null: NEVER [], and never copy the saved organization UUID into a new scope override. Non-null link_scopes are only for explicitly requested custom per-resource scope overrides, never invented tenant/project scope IDs. Platform drafts use projectTemplates keyed by stable template key, not concrete landing_zones/sandboxes or personal instance fields. Never include template IDs: the server preserves/generates them. A new template needs name, kind, region and settings together. Corporate templates require an existing same-region network area; sandbox region is the platform region and has no namespace services. Namespace settings and parameterPolicy are template settings; no publication or application creation. For the desired complete set use replace, listing exactly the desired keys; omitted templates become explicit selectable removal proposals. Reuse suitable existing keys and preserve their unrequested attributes. Template settings/parameter policies are validated against native types and the Editor's policy contract. -When the user delegates template naming, choose descriptive names for the requested business variants instead of asking for names again. Central-network projects are corporate; Internet-only projects without central-network access are public. A requested local project network means settings.network_enabled=true for each requested variant. Use the saved platform region and an unambiguous existing same-region area for Corporate; ask for the area only when missing or ambiguous. Reuse existing subnet defaults unless the user specifies a prefix; omit network_prefix_length to inherit the native default. Additional templates use merge; a desired complete set uses replace. For a network-only template request, settings contains only network_enabled and the Corporate network_area_key when needed; omit observability, secretsmanager_enabled, namespace services and other unrequested settings. Platform Audit/Git/services are NOT template namespace-service consent or defaults. Do not ask for personal instance ownership or create a Sandbox the user excludes. Preserve unrelated existing templates for add/update, not entries excluded from a requested complete set. +When the user delegates template naming, choose descriptive names for the requested business variants instead of asking for names again. Central-network projects are corporate; Internet-only projects without central-network access are public. A requested local project network means settings.network_enabled=true for each requested variant. Use the saved platform region and an unambiguous existing same-region area for Corporate; ask for the area only when missing or ambiguous. Reuse existing explicit subnet sizes unless the user requests a change. For new Automatic templates omit network_prefix_length (or null when explicitly requested): the corrected native Corporate module reads the attached regional SNA default_prefix_length during Plan and supplies it to the provider while preserving DNS. Public Automatic remains provider-managed. Never replace Automatic with a fixed /24, promise free addresses or require a numeric size when Automatic was chosen. A saved draft SNA value is not proof of the current cloud default. Explicit size overrides require the requested value and preserve unrelated template settings. Additional templates use merge; a desired complete set uses replace. For a network-only template request, settings contains only network_enabled and the Corporate network_area_key when needed; omit observability, secretsmanager_enabled, namespace services and other unrequested settings. Platform Audit/Git/services are NOT template namespace-service consent or defaults. Do not ask for personal instance ownership or create a Sandbox the user excludes. Preserve unrelated existing templates for add/update, not entries excluded from a requested complete set. DNS corrections use the existing zone key, never a new entry merely to change its domain/display name. With multiple ambiguous zones ask for the target. Explicitly additional zones use merge on the DNS collection; a complete set uses replace. Preserve unrelated network/firewall settings. dnsCollection is a source-grounded USER decision, not permission to invent domains. For an additional DNS zone, emit the actual collection change containing the requested new key and dns_name. Include every requested zone setting in that entry, including contact_email from the saved owner_email when requested; explaining that a contact is used without including it in changes is not a proposal. Existing unrelated zones are preserved by merge, not by returning an empty changes list. Return changes=[] only when essential information needs a question or the requested effective state is already present. templateSetup contains source-checked USER decisions: its kinds and localNetworks are answered requirements, not questions to repeat. Its fields is the allowed template setting scope; the schema excludes unrequested services and policies. delegateNaming=true authorizes descriptive template display names and valid stable template keys; they are not Cloud IDs. When saved region and the Corporate area are unambiguous, these decisions suffice for complete name/kind/region/settings template proposals. Optional parameter policies and subnet overrides are not mandatory setup questions. @@ -2306,7 +2306,16 @@ Reviewed knowledge: ${JSON.stringify({ fields: draftFields, ...topicKnowledge }) { role: "system" as const, content: - "ANSWER COMPLETENESS: Put the complete factual answer in explanation itself, in the user's language. Use compact bullets and at most 2400 characters, leaving room within the 3000-character response contract. Group equivalent central/regional forms and nested field families instead of repeating long paths in prose. An introduction announcing rules or a list is not an answer: actually include the requested fields, supported values, limits and exclusions from the reviewed topic. Contract names, versions, dates and references are provenance, not substitutes for those facts. Explain native defaults, including null meaning no optional service instance, where relevant; do not promote illustrative enabled values to defaults. For a pure fields/defaults/limits overview return questions=[]: no configuration decisions are missing to answer that informational request. Otherwise questions may ask only genuinely missing business decisions after the factual answer; never request technical IDs, secrets or bootstrap credentials, or move the answer's facts into questions. Before finishing, check that explanation stands alone without an absent continuation or a promised list. Do not invent facts, propose changes or claim anything was saved or executed.", + "ANSWER COMPLETENESS: Put the complete factual answer in explanation itself, in the user's language. Use compact bullets and at most 2400 characters, leaving room within the 3000-character response contract. Group equivalent central/regional forms and nested field families instead of repeating long paths in prose. An introduction announcing rules or a list is not an answer: actually include the requested fields, supported values, limits and exclusions from the reviewed topic. Contract names, versions, dates and references are provenance, not substitutes for those facts. For project-template network sizing, distinguish template null from the effective non-null provider prefix: use the reviewed topic facts, explain which layer reads the SNA default and keeps DNS, and never invert the failing null+DNS combination. A requested Automatic choice needs no numeric sizing question. State that inherited size does not guarantee available addresses. Explain native defaults, including null meaning no optional service instance, where relevant; do not promote illustrative enabled values to defaults. For a pure fields/defaults/limits overview return questions=[]: no configuration decisions are missing to answer that informational request. Otherwise questions may ask only genuinely missing business decisions after the factual answer; never request technical IDs, secrets or bootstrap credentials, or move the answer's facts into questions. Before finishing, check that explanation stands alone without an absent continuation or a promised list. Do not invent facts, propose changes or claim anything was saved or executed.", + }, + ] + : []), + ...(intent.topic === "project-templates" && !draftAllowed + ? [ + { + role: "system" as const, + content: + "NETWORK SIZING FACTS (apply only when relevant; explain only these relevant facts, no invented release numbers): distinguish TEMPLATE network_prefix_length from PROVIDER ipv4_prefix_length. The EXACT SAME STACKIT provider 0.114.0 is used before AND after the Accelerator module fix. No provider upgrade fixes this. Provider inputs {prefix:null,DNS:configured or []} -> INVALID before AND after; {prefix:null,DNS:null} -> automatic accepted. FIXED Corporate module, not the provider, READS actual regional SNA default during Plan; template null -> module resolves SNA default -> provider receives NUMERIC prefix and UNCHANGED DNS. Thus DNS is neither required to make Automatic work nor disabled by the fix. Native module source changed; provider validation did not. Never call this a provider-version fix; topic version2 and checkedOn are knowledge metadata, not software releases. Do not reverse the old failure condition. For SNA default28: template null -> provider prefix28, not null. Existing published sources need new corrected version/order; this conversation saves or executes nothing. For a sizing question answer sizing, not an unsolicited field/default overview. Distinguish the corrected source from the deployed service: existing old versions are not fixed by this advice.", }, ] : []), diff --git a/landing-zone-configurator/app/apps/web/src/components/Applications.tsx b/landing-zone-configurator/app/apps/web/src/components/Applications.tsx index b65aca6a..4a738bbf 100644 --- a/landing-zone-configurator/app/apps/web/src/components/Applications.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/Applications.tsx @@ -147,8 +147,10 @@ const errors: Record = { "Die Plattformquelle hat sich geändert. Bitte erneut prüfen.", verified_application_identity_required: "Bitte mit STACKIT für die Organisation dieses Tenants erneut anmelden.", + application_order_identity_completion_unavailable: + "Diese Bestellung hat bereits Freigaben oder Ausführungsnachweise. Die fehlende Bestellerbindung kann nicht automatisch ergänzt werden. Bitte vom Platform Owner prüfen lassen.", application_owner_identity_changed: - "Die verifizierte Besteller-E-Mail hat sich geändert. Bitte eine neue Bestellung anlegen.", + "Die verifizierte STACKIT-Identität passt nicht zur bestehenden Bestellerbindung. Melde dich mit der ursprünglich verwendeten Identität an; vorhandene Ausführungsnachweise bleiben erhalten.", application_policy_mismatch: "Die Bereitstellungsrichtlinie dieser Bestellung stimmt nicht mit der veröffentlichten Version überein.", application_parameters_not_qualified: @@ -2524,6 +2526,7 @@ export function Applications({ ) throw new Error("Plan-Input gehört nicht zu dieser Bestellung."); setPlanInput(prepared); + if (selectedInstance.settings.owner_email == null) await load(); } catch (cause) { setError( cause instanceof Error diff --git a/landing-zone-configurator/app/apps/web/src/components/ProjectTemplates.tsx b/landing-zone-configurator/app/apps/web/src/components/ProjectTemplates.tsx index 1d145086..1d48df8c 100644 --- a/landing-zone-configurator/app/apps/web/src/components/ProjectTemplates.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/ProjectTemplates.tsx @@ -282,7 +282,7 @@ export function ProjectTemplates({ className="field-hint" > {t( - "Bestimmt die Größe des IPv4-Adressbereichs: /24 umfasst 256 Adressen, /25 umfasst 128, /26 umfasst 64. Je höher die Zahl, desto kleiner das Netz. Nicht alle Adressen stehen für VMs zur Verfügung. Leer lassen: STACKIT legt die Größe fest.", + "Bestimmt die Größe des IPv4-Adressbereichs: /24 umfasst 256 Adressen, /25 umfasst 128, /26 umfasst 64. Je höher die Zahl, desto kleiner das Netz. Nicht alle Adressen stehen für VMs zur Verfügung. Leer lassen für Automatisch: Corporate übernimmt die Standardgröße der angebundenen SNA, Public die Vorgabe von STACKIT.", )}

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 b8a571f4..7374a85b 100644 --- a/landing-zone-configurator/app/apps/web/src/locales/en.ts +++ b/landing-zone-configurator/app/apps/web/src/locales/en.ts @@ -893,8 +893,8 @@ export const englishMessages: Record = { "Order saved. No cloud resources were created.", "Bestellung testen": "Test order", Bestellungen: "Orders", - "Bestimmt die Größe des IPv4-Adressbereichs: /24 umfasst 256 Adressen, /25 umfasst 128, /26 umfasst 64. Je höher die Zahl, desto kleiner das Netz. Nicht alle Adressen stehen für VMs zur Verfügung. Leer lassen: STACKIT legt die Größe fest.": - "Sets the IPv4 address range size: /24 contains 256 addresses, /25 contains 128, and /26 contains 64. Higher numbers mean smaller networks. Not all addresses are available to VMs. Leave empty to let STACKIT set the size.", + "Bestimmt die Größe des IPv4-Adressbereichs: /24 umfasst 256 Adressen, /25 umfasst 128, /26 umfasst 64. Je höher die Zahl, desto kleiner das Netz. Nicht alle Adressen stehen für VMs zur Verfügung. Leer lassen für Automatisch: Corporate übernimmt die Standardgröße der angebundenen SNA, Public die Vorgabe von STACKIT.": + "Sets the IPv4 address range size: /24 contains 256 addresses, /25 contains 128, and /26 contains 64. Higher numbers mean smaller networks. Not all addresses are available to VMs. Leave empty for Automatic: Corporate inherits the attached SNA default size; Public uses the STACKIT default.", Betrieb: "Operations", Betriebssystem: "Operating system", "Bezieht sich auf das Dateisystem des späteren Runners, nicht auf deinen Computer. Alternativ den öffentlichen Schlüssel hinterlegen.": @@ -1248,8 +1248,10 @@ export const englishMessages: Record = { "Could not verify the state assignment.", "Die tfvars-Datei wurde außerhalb des Configurators geändert oder passt nicht mehr zum gespeicherten Entwurf. Prüfe den Stand in GitHub oder speichere deinen Entwurf als neue Kopie. Es wurde nichts überschrieben.": "The tfvars file changed outside the configurator or no longer matches the saved draft. Check the GitHub version or save your draft as a new copy. Nothing was overwritten.", - "Die verifizierte Besteller-E-Mail hat sich geändert. Bitte eine neue Bestellung anlegen.": - "The verified orderer's email address changed. Please create a new order.", + "Die verifizierte STACKIT-Identität passt nicht zur bestehenden Bestellerbindung. Melde dich mit der ursprünglich verwendeten Identität an; vorhandene Ausführungsnachweise bleiben erhalten.": + "The verified STACKIT identity does not match the existing order owner binding. Sign in with the identity originally used; existing execution evidence is retained.", + "Diese Bestellung hat bereits Freigaben oder Ausführungsnachweise. Die fehlende Bestellerbindung kann nicht automatisch ergänzt werden. Bitte vom Platform Owner prüfen lassen.": + "This order already has approvals or execution evidence. The missing owner binding cannot be completed automatically. Please ask the Platform Owner to review it.", "Die vier Ordnerrollen bestimmen die Projektzuordnung. Anzeigenamen und Zugriffsrechte können hier geändert werden. Ordnerbeschreibungen werden vom Accelerator derzeit nicht wirksam übernommen.": "The four folder roles determine project assignments. Display names and permissions can be changed here. Folder descriptions are not currently applied by the accelerator.", "Die Zielorganisation stimmt nicht mit der gespeicherten Vorbereitung überein. Prüfe die exakte Organisations-ID.": diff --git a/landing-zone-configurator/app/e2e/organisation.spec.ts b/landing-zone-configurator/app/e2e/organisation.spec.ts index c0884975..912069c4 100644 --- a/landing-zone-configurator/app/e2e/organisation.spec.ts +++ b/landing-zone-configurator/app/e2e/organisation.spec.ts @@ -3692,3 +3692,206 @@ for (const manager of [true, false]) { ); }); } + +for (const outcome of [ + "completed", + "already-used", + "different-identity", +] as const) { + test(`application order identity completion ${outcome} retains the existing order`, async ({ + page, + }) => { + const instanceId = "44444444-4444-4444-8444-444444444444"; + const missingProof = + "Verifizierte STACKIT-Benutzeridentität für diese Bestellung fehlt."; + const tenant = { + id: tenantId, + name: "Pilot", + kind: "organisation", + organizationId: organisationId, + roles: ["application-owner"], + manageMembers: false, + }; + let completed = false; + let reads = 0; + const mutations: string[] = []; + const instance = { + id: instanceId, + versionId: "55555555-5555-4555-8555-555555555555", + requestedBy: userId, + name: "Existing identity order", + deploymentPolicy: "direct", + approval: { status: "not-required" }, + parameters: {}, + settings: (outcome === "different-identity" + ? { owner_email: "original@example.test" } + : {}) as Record, + createdAt: "2026-10-08T10:15:00.000Z", + stateKey: `applications/${tenantId}/${instanceId}/terraform.tfstate`, + executionEnabled: false, + planStatus: "blocked", + blockers: [ + ...(outcome === "different-identity" ? [] : [missingProof]), + "Der isolierte Application-Plan-Runner ist noch nicht freigegeben. Es wurde kein Cloud-Plan ausgeführt.", + ], + }; + await page.route("**/auth/status", (route) => + route.fulfill({ + json: { stackit: true, github: false, primary: "stackit" }, + }), + ); + await page.route("**/api/v1/session", (route) => + route.fulfill({ + json: { + user: { id: userId, login: "pilot@example.test" }, + tenant, + csrfToken: "csrf-identity", + expiresAt: new Date(Date.now() + 3600000).toISOString(), + }, + }), + ); + await page.route("**/api/v1/organisation", (route) => + route.fulfill({ + json: { + userId, + activeTenantId: tenantId, + tenants: [tenant], + members: [], + }, + }), + ); + await page.route("**/api/v1/applications/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname.replace( + "/api/v1/applications/", + "", + ); + expect(request.headers()["x-lzc-tenant"]).toBe(tenantId); + if (request.method() === "GET") { + if (path === "templates") + return route.fulfill({ json: { versions: [] } }); + if (path === "platform-contracts") + return route.fulfill({ json: { contracts: [] } }); + if (path === `instances/${instanceId}/jobs`) + return route.fulfill({ json: { jobs: [] } }); + if (path === "instances") { + reads++; + return route.fulfill({ + json: { + instances: [instance], + planJobsEnabled: true, + execution: { planEnabled: true, applyEnabled: false }, + }, + }); + } + } + mutations.push(path); + expect(request.method()).toBe("POST"); + expect(request.headers()["x-lzc-csrf"]).toBe("csrf-identity"); + expect(path).toBe(`instances/${instanceId}/plan-input`); + expect(request.postDataJSON()).toEqual({}); + if (outcome !== "completed") + return route.fulfill({ + status: 409, + json: { + error: + outcome === "already-used" + ? "application_order_identity_completion_unavailable" + : "application_owner_identity_changed", + }, + }); + completed = true; + instance.settings = { owner_email: "pilot@example.test" }; + instance.blockers = [ + "Der isolierte Application-Plan-Runner ist noch nicht freigegeben. Es wurde kein Cloud-Plan ausgeführt.", + ]; + return route.fulfill({ + json: { + kind: "application-plan-input", + cloudPlanExecuted: false, + requiresExplicitApplyApproval: true, + blockers: [], + plan: { + entrypoint: "src/application", + acceleratorRevision: "1ed855609d4dcbfded6759c7c8b4fabf7bd6725d", + stateKey: instance.stateKey, + requestedBy: userId, + executionEnabled: false, + variables: { + application: { + instance_id: instanceId, + owner_email: "pilot@example.test", + }, + }, + }, + }, + }); + }); + await page.goto("/applications"); + await page.getByRole("tab", { name: "Bestellungen", exact: true }).click(); + await page + .getByRole("button", { + name: /Details anzeigen: Existing identity order/, + }) + .click(); + const details = page.getByRole("region", { + name: "Bestelldetails", + exact: true, + }); + await details.getByRole("tab", { name: "Übersicht", exact: true }).click(); + if (outcome === "different-identity") { + await expect(details).toContainText( + "Bei Bestellung verifiziert: original@example.test", + ); + } else { + await expect(details).toContainText(missingProof); + await expect(details).toContainText("Bei Bestellung nicht verifiziert"); + } + const readsBefore = reads; + await details + .getByRole("button", { name: "Plan-Input prüfen", exact: true }) + .click(); + if (outcome === "completed") { + await expect(details).toContainText( + "Bei Bestellung verifiziert: pilot@example.test", + ); + await expect(details).not.toContainText(missingProof); + await expect( + details.getByRole("heading", { + name: "Plan-Input geprüft · Kein Cloud-Plan ausgeführt", + exact: true, + }), + ).toBeVisible(); + expect(reads).toBeGreaterThan(readsBefore); + await details.getByRole("tab", { name: "Plan", exact: true }).click(); + await expect( + details.getByRole("button", { + name: "Cloud-Plan starten", + exact: true, + }), + ).toBeEnabled(); + } else { + const message = + outcome === "already-used" + ? "Diese Bestellung hat bereits Freigaben oder Ausführungsnachweise." + : "Die verifizierte STACKIT-Identität passt nicht zur bestehenden Bestellerbindung."; + await expect(page.getByRole("alert")).toContainText(message); + await expect(page.getByRole("alert")).not.toContainText( + "Bitte eine neue Bestellung anlegen", + ); + await page.getByLabel("Sprache", { exact: true }).selectOption("en"); + await expect(page.getByRole("alert")).toContainText( + outcome === "already-used" + ? "This order already has approvals or execution evidence." + : "The verified STACKIT identity does not match the existing order owner binding.", + ); + } + expect(completed).toBe(outcome === "completed"); + expect(instance.id).toBe(instanceId); + expect(instance.stateKey).toBe( + `applications/${tenantId}/${instanceId}/terraform.tfstate`, + ); + expect(mutations).toEqual([`instances/${instanceId}/plan-input`]); + await expect(page.getByRole("table").getByRole("rowheader")).toHaveCount(1); + }); +} diff --git a/landing-zone-configurator/app/integration/identity.test.ts b/landing-zone-configurator/app/integration/identity.test.ts index 19507a0e..0fa8ae73 100644 --- a/landing-zone-configurator/app/integration/identity.test.ts +++ b/landing-zone-configurator/app/integration/identity.test.ts @@ -3853,6 +3853,235 @@ describe("real PostgreSQL session and tenant boundaries", () => { tokenExpiresAt: new Date(Date.now() + 300000).toISOString(), organization: null, }); + const identityVersion = await applications.publish(engineer, { + template: { + ...template, + id: randomUUID(), + key: "identity-completion", + name: "Complete verified owner", + settings: { + ...template.settings, + role_assignments: [ + { role: "auditor", subject: "fixed@example.test" }, + ], + }, + parameterPolicy: { + schema_version: 1, + fields: { + role_assignments: { + source: "context", + variable: "verified-project-owner", + roles: ["owner"], + }, + }, + }, + }, + ...binding, + acceleratorRevision: "9e7fb46c329aca32e878ce1f0ea3b944ea240354", + deploymentPolicy: "direct", + }); + await identities.revoke(owner); + const unboundOrder = await applications.order(owner, { + versionId: identityVersion.id, + idempotencyKey: randomUUID(), + name: "Before human login", + parameters: {}, + }); + expect(unboundOrder.settings.owner_email).toBeUndefined(); + await expect( + applications.preparePlanInput(owner, unboundOrder.id), + ).rejects.toMatchObject({ + status: 403, + code: "verified_application_identity_required", + }); + const originalOrder = ( + await migration.query( + "SELECT to_jsonb(i)-ARRAY['resolved_settings','qualification_blockers'] AS metadata FROM lzc.application_instances i WHERE id=$1", + [unboundOrder.id], + ) + ).rows[0].metadata; + await identities.save(owner, { + ...ownerProof, + tokenExpiresAt: new Date(Date.now() + 300000).toISOString(), + organization: null, + }); + await expect( + applications.preparePlanInput(engineer, unboundOrder.id), + ).rejects.toMatchObject({ status: 404 }); + await expect( + withTenant(pool, engineer, (client) => + client.query( + "SELECT lzc_auth.complete_application_order_identity($1,$2,'{}')", + [engineer.id, unboundOrder.id], + ), + ), + ).rejects.toMatchObject({ code: "42501" }); + await expect( + withTenant(pool, owner, (client) => + client.query( + "SELECT lzc_auth.complete_application_order_identity($1,$2,$3)", + [ + owner.id, + unboundOrder.id, + JSON.stringify({ + ...unboundOrder.settings, + owner_email: ownerProof.email, + network_enabled: false, + }), + ], + ), + ), + ).rejects.toMatchObject({ code: "42501" }); + await expect( + withTenant(pool, bob, (client) => + client.query( + "SELECT lzc_auth.complete_application_order_identity($1,$2,'{}')", + [bob.id, unboundOrder.id], + ), + ), + ).rejects.toMatchObject({ code: "42501" }); + const identityCompletionPool = new pg.Pool({ + ...migrationConfig, + user: "configurator_app", + password: "runtime-test-only", + max: 2, + }); + const completionApplications = new Applications(identityCompletionPool); + const completedPlans = await Promise.all([ + completionApplications.preparePlanInput(owner, unboundOrder.id), + completionApplications.preparePlanInput(owner, unboundOrder.id), + ]).finally(() => identityCompletionPool.end()); + const completedPlan = completedPlans[0]; + if (!completedPlan) throw new Error("Missing completed plan input"); + expect(completedPlans[1]?.plan.stateKey).toBe( + completedPlan.plan.stateKey, + ); + expect(completedPlan.plan.variables.application.owner_email).toBe( + ownerProof.email, + ); + expect(completedPlan.plan.stateKey).toBe( + `applications/${tenantId}/${unboundOrder.id}/terraform.tfstate`, + ); + const completedOrder = (await applications.listInstances(owner)).find( + (item) => item.id === unboundOrder.id, + ); + expect(completedOrder?.settings.role_assignments).toEqual([ + { role: "auditor", subject: "fixed@example.test" }, + { role: "owner", subject: ownerProof.email }, + ]); + expect(completedOrder?.blockers).not.toContain( + "Verifizierte STACKIT-Benutzeridentität für diese Bestellung fehlt.", + ); + expect( + ( + await migration.query( + "SELECT to_jsonb(i)-ARRAY['resolved_settings','qualification_blockers'] AS metadata FROM lzc.application_instances i WHERE id=$1", + [unboundOrder.id], + ) + ).rows[0].metadata, + ).toEqual(originalOrder); + await applications.preparePlanInput(owner, unboundOrder.id); + const completionAudit = ( + await migration.query( + "SELECT initial_settings,completed_settings,subject FROM lzc.application_order_identity_completions WHERE instance_id=$1", + [unboundOrder.id], + ) + ).rows; + expect(completionAudit).toHaveLength(1); + expect(completionAudit[0].initial_settings.owner_email).toBeUndefined(); + expect(completionAudit[0].completed_settings.owner_email).toBe( + ownerProof.email, + ); + expect(completionAudit[0].subject).toBe(ownerProof.subject); + await expect( + migration.query( + "DELETE FROM lzc.application_order_identity_completions WHERE instance_id=$1", + [unboundOrder.id], + ), + ).rejects.toMatchObject({ code: "55000" }); + await expect( + withTenant(pool, owner, (client) => + client.query( + "UPDATE lzc.application_instances SET resolved_settings='{}' WHERE id=$1", + [unboundOrder.id], + ), + ), + ).rejects.toMatchObject({ code: "42501" }); + await identities.save(owner, { + ...ownerProof, + email: "changed-owner@example.test", + tokenExpiresAt: new Date(Date.now() + 300000).toISOString(), + organization: null, + }); + await expect( + applications.preparePlanInput(owner, unboundOrder.id), + ).rejects.toMatchObject({ + status: 409, + code: "application_owner_identity_changed", + }); + await identities.save(owner, { + ...ownerProof, + tokenExpiresAt: new Date(Date.now() + 300000).toISOString(), + organization: null, + }); + const jobBoundaryOrder = await applications.order(owner, { + versionId: identityVersion.id, + idempotencyKey: randomUUID(), + name: "Job identity boundary", + parameters: {}, + }); + await migration.query( + "UPDATE lzc.tenants SET organization_verified=true WHERE id=$1", + [tenantId], + ); + const boundJob = await applications.prepareJob( + owner, + jobBoundaryOrder.id, + { + idempotencyKey: randomUUID(), + confirmPlan: true, + }, + ); + await migration.query( + "UPDATE lzc.application_instances SET resolved_settings=resolved_settings-'owner_email' WHERE id=$1", + [jobBoundaryOrder.id], + ); + await expect( + applications.preparePlanInput(owner, jobBoundaryOrder.id), + ).rejects.toMatchObject({ + status: 409, + code: "application_order_identity_completion_unavailable", + }); + expect( + (await applications.listJobs(owner, jobBoundaryOrder.id))[0]?.id, + ).toBe(boundJob.id); + await migration.query( + "UPDATE lzc.tenants SET organization_verified=false WHERE id=$1", + [tenantId], + ); + await identities.revoke(owner); + const approvedUnboundOrder = await applications.order(owner, { + versionId: first.id, + idempotencyKey: randomUUID(), + name: "Approved before login", + parameters: {}, + }); + await applications.decideOrder(engineer, approvedUnboundOrder.id, { + decision: "approved", + reason: "Reviewed before proof", + confirmDecision: true, + }); + await identities.save(owner, { + ...ownerProof, + tokenExpiresAt: new Date(Date.now() + 300000).toISOString(), + organization: null, + }); + await expect( + applications.preparePlanInput(owner, approvedUnboundOrder.id), + ).rejects.toMatchObject({ + status: 409, + code: "application_order_identity_completion_unavailable", + }); const order = await applications.order(owner, { versionId: first.id, idempotencyKey: randomUUID(), @@ -3865,6 +4094,14 @@ describe("real PostgreSQL session and tenant boundaries", () => { "Der isolierte Application-Plan-Runner ist noch nicht freigegeben. Es wurde kein Cloud-Plan ausgeführt.", ]); const prepared = await applications.preparePlanInput(owner, order.id); + expect( + ( + await migration.query( + "SELECT count(*)::int AS total FROM lzc.application_order_identity_completions WHERE instance_id=$1", + [order.id], + ) + ).rows[0].total, + ).toBe(0); expect(prepared).toMatchObject({ kind: "application-plan-input", cloudPlanExecuted: false, diff --git a/landing-zone-configurator/app/packages/contracts/src/plan-output.ts b/landing-zone-configurator/app/packages/contracts/src/plan-output.ts index 9effdfcc..8e0fe4ea 100644 --- a/landing-zone-configurator/app/packages/contracts/src/plan-output.ts +++ b/landing-zone-configurator/app/packages/contracts/src/plan-output.ts @@ -47,6 +47,16 @@ export function openTofuProgress(content: string) { continue; } if (event.data.type !== "outputs") messages.push(event.data["@message"]); + if (event.data.type === "diagnostic") { + const diagnostic = z + .object({ diagnostic: z.object({ detail: z.string() }) }) + .safeParse(raw); + const detail = diagnostic.success + ? diagnostic.data.diagnostic.detail + : ""; + if (detail.trim() && !event.data["@message"].includes(detail)) + messages.push(detail); + } } if (invalid) messages.push("[OpenTofu progress event unavailable]"); return { text: `${messages.join("\n")}\n`, invalid }; diff --git a/landing-zone-configurator/app/packages/contracts/src/plan.ts b/landing-zone-configurator/app/packages/contracts/src/plan.ts index f845e607..8c4a9b5c 100644 --- a/landing-zone-configurator/app/packages/contracts/src/plan.ts +++ b/landing-zone-configurator/app/packages/contracts/src/plan.ts @@ -1,12 +1,13 @@ import { z } from "zod"; export const applicationMvpAcceleratorCommit = - "9e7fb46c329aca32e878ce1f0ea3b944ea240354"; + "1ed855609d4dcbfded6759c7c8b4fabf7bd6725d"; export const applicationRunnerSourceSchema = z.strictObject({ schemaVersion: z.literal(1), acceleratorCommit: z.enum([ "88149782bf8e91dcdbb43a203b54337886023f7f", "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f", + "9e7fb46c329aca32e878ce1f0ea3b944ea240354", applicationMvpAcceleratorCommit, ]), maintenanceEnabled: z.literal(true).optional(), diff --git a/landing-zone-configurator/app/packages/domain/src/application-catalogue.ts b/landing-zone-configurator/app/packages/domain/src/application-catalogue.ts index 718983f5..0480a2ad 100644 --- a/landing-zone-configurator/app/packages/domain/src/application-catalogue.ts +++ b/landing-zone-configurator/app/packages/domain/src/application-catalogue.ts @@ -13,6 +13,7 @@ export const applicationAcceleratorRevisionSchema = z.enum([ "88149782bf8e91dcdbb43a203b54337886023f7f", "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f", "9e7fb46c329aca32e878ce1f0ea3b944ea240354", + "1ed855609d4dcbfded6759c7c8b4fabf7bd6725d", ]); export const applicationPublicationSchema = z @@ -125,6 +126,7 @@ export function applicationPlanScopeIssues( ); if ( acceleratorRevision !== "9e7fb46c329aca32e878ce1f0ea3b944ea240354" && + acceleratorRevision !== "1ed855609d4dcbfded6759c7c8b4fabf7bd6725d" && (template.kind !== "public" || settings.network_enabled !== true) ) issues.push( diff --git a/landing-zone-configurator/app/scripts/assistant-scenarios.ts b/landing-zone-configurator/app/scripts/assistant-scenarios.ts index b06e9f75..a3baaae4 100644 --- a/landing-zone-configurator/app/scripts/assistant-scenarios.ts +++ b/landing-zone-configurator/app/scripts/assistant-scenarios.ts @@ -123,12 +123,14 @@ export type AssistantScenario = { | "firewall-new" | "firewall-ha" | "firewall-metro" - | "sna"; + | "sna" + | "corporate-ipv4"; acceptableReferences?: readonly string[]; requiredOverviewFields?: readonly string[]; editorExpected?: { path: string; value: JsonValue }; dnsRole?: "roles" | "secondary" | "draft"; templateExpected?: ("public" | "corporate")[]; + corporateIpv4?: "advice" | "automatic" | "explicit"; history?: readonly { role: "user" | "assistant"; content: string }[]; }; @@ -209,6 +211,30 @@ export const assistantScenarios: readonly AssistantScenario[] = [ message: "Ergänze zusätzlich genau eine bei STACKIT verwaltete öffentliche DNS-Zone new-company.stackit.gg unter dem Schlüssel company. Die vorhandene Zone example und alle Netzwerke bleiben unverändert. Wähle den fachlich passenden Zonentyp und verwende die hinterlegte Owner-E-Mail als Kontakt. Nur einen überprüfbaren Entwurf vorschlagen, nichts deployen.", }, + { + id: "corporate-ipv4-automatic-advice", + topic: "project-templates", + fixture: "baseline", + corporateIpv4: "advice", + message: + "Unser Corporate-Template nutzt Automatisch, die angebundene SNA hat Standardpräfix /28 und DNS-Server. Erkläre, wie Automatisch im korrigierten Accelerator funktioniert und warum zuvor Invalid IPv4 configuration kam. Muss ich zwingend /24 setzen oder DNS ausschalten? Nur beraten, nichts ändern; keine freien Adressen behaupten.", + }, + { + id: "corporate-ipv4-explicit-proposal", + topic: "project-templates", + fixture: "corporate-ipv4", + corporateIpv4: "explicit", + message: + "Ändere nur die Netzgröße der bestehenden Corporate-Vorlage central auf ausdrücklich /26. Der Adressbedarf und die SNA-Grenzen sind dafür geprüft. Schlüssel, Name, Dienste und DNS unverändert lassen. Nur überprüfbarer Editor-Vorschlag, keine Veröffentlichung oder Ausführung.", + }, + { + id: "corporate-ipv4-automatic-proposal", + topic: "project-templates", + fixture: "corporate-ipv4", + corporateIpv4: "automatic", + message: + "Setze nur die Netzgröße der bestehenden Corporate-Vorlage central von /26 zurück auf Automatisch, damit der Standard der angebundenen SNA verwendet wird. Keinen festen Ersatzwert setzen, DNS und alle anderen Einstellungen erhalten. Nur überprüfbarer Editor-Vorschlag, keine Veröffentlichung oder Ausführung.", + }, { id: "platform-public-corporate-networks", topic: "project-templates", @@ -593,7 +619,11 @@ export function assistantScenarioFixture(scenario: AssistantScenario) { organization_id: syntheticOrganizationId, })) draft = setCommonInput(draft, path, value); - if (scenario.fixture.startsWith("dns-") || scenario.fixture === "sna") + if ( + scenario.fixture.startsWith("dns-") || + scenario.fixture === "sna" || + scenario.corporateIpv4 + ) draft = commonConfigurationFromValues(template, draft.id, draft.name, { ...compileCommonConfiguration(draft), connectivity: { @@ -603,7 +633,7 @@ export function assistantScenarioFixture(scenario: AssistantScenario) { transfer_network: "10.254.255.0/24", min_prefix_length: 24, max_prefix_length: 28, - default_prefix_length: 24, + default_prefix_length: scenario.corporateIpv4 ? 28 : 24, default_nameservers: ["1.1.1.1"], }, }, @@ -738,6 +768,25 @@ export function assistantScenarioFixture(scenario: AssistantScenario) { } if (scenario.id === "editor-project-unique-buckets") draft = setCommonInput(draft, "bucket_naming", "legacy"); + if (scenario.corporateIpv4) { + draft = createPlatformDraftCopy(draft, crypto.randomUUID()); + draft.projectTemplates = [ + { + id: "00000000-0000-4000-8000-000000000026", + key: "central", + name: "Corporate central", + kind: "corporate", + region: "eu01", + settings: { + network_enabled: true, + network_area_key: "default", + network_prefix_length: + scenario.corporateIpv4 === "automatic" ? 26 : null, + secretsmanager_enabled: true, + }, + }, + ]; + } const values = assistantPublicValues(draft); if (scenario.fixture === "governance-verified-email") values["assistant.user_email"] = "login@example.test"; @@ -807,6 +856,32 @@ export function validateAssistantScenario( const fixture = assistantScenarioFixture(scenario); if (scenario.id === "devops-git-advice") failures.push(...assistantDevOpsDefaultFailures(dialogue.explanation)); + if (scenario.corporateIpv4 === "advice") { + failures.push(...assistantCorporateIpv4Failures(dialogue.explanation)); + if (dialogue.changes.length) + failures.push("corporate_ipv4_advice_mutated_draft"); + if (dialogue.questions.length) + failures.push("corporate_ipv4_automatic_requested_numeric_size"); + } else if (scenario.corporateIpv4) { + const expected = structuredClone(fixture.draft); + const central = expected.projectTemplates?.find( + (entry) => entry.key === "central", + ); + if (!central) throw new Error("corporate_ipv4_fixture_missing"); + central.settings.network_prefix_length = + scenario.corporateIpv4 === "automatic" ? null : 26; + try { + const candidate = applyAssistantChanges(fixture.draft, dialogue.changes); + if ( + !dialogue.changes.length || + dialogue.questions.length || + !isDeepStrictEqual(candidate, expected) + ) + failures.push("corporate_ipv4_proposal_incorrect_or_unrelated_change"); + } catch { + failures.push("corporate_ipv4_proposal_invalid"); + } + } if (scenario.templateExpected) { if ( !dialogue.changes.length || @@ -1538,3 +1613,64 @@ export function buildAssistantScenarioInventory() { })), }; } + +/** Bounded factual regression, not a proof of universal advice quality. */ +export function assistantCorporateIpv4Failures(explanation: string): string[] { + const failures: string[] = []; + const text = explanation + .toLowerCase() + .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, ""); + const unnegatedClaim = (pattern: RegExp) => + [...text.matchAll(pattern)].some( + (match) => !/\b(?:nicht|not|never|kein\w*)\b/.test(match[0]), + ); + if ( + !/automat/.test(text) || + !/sna|network area/.test(text) || + !/standard|default/.test(text) || + !/28/.test(text) || + !/übern|uebern|erbt|inherit|lies|liest|read|abfrag/.test(text) + ) + failures.push("corporate_ipv4_automatic_inheritance_missing"); + if ( + !/dns|nameserver/.test(text) || + !/präfix|praefix|prefix/.test(text) || + !/provider/.test(text) || + !/explizit|explicit|numerisch|numeric/.test(text) + ) + failures.push("corporate_ipv4_provider_dependency_missing"); + if ( + !/dns[^.!?]*(?:erhalt|unverändert|unveraendert|beibeh|beibe|retain|preserv|bleibt aktiv)|(?:erhalt|beibeh|beibe|unverändert|unveraendert|retain|preserv)[^.!?]*dns/.test( + text, + ) + ) + failures.push("corporate_ipv4_dns_preservation_missing"); + if ( + !/kein[^.!?]*(?:garant|kapaz|verfügbar|verfuegbar|frei)|nicht[^.!?]*(?:garant|frei)|no[^.!?]*(?:guarantee|free)|nicht garantiert/.test( + text, + ) + ) + failures.push("corporate_ipv4_capacity_boundary_missing"); + if ( + unnegatedClaim( + /(?:du musst|you must|zwingend erforderlich)[^.!?]{0,45}\/24|dns[^.!?]{0,35}(?:muss|must)[^.!?]{0,30}(?:ab(?:ge)?schalt|deaktiv|disable)/g, + ) + ) + failures.push("corporate_ipv4_invented_fixed_size_or_dns_removal"); + if ( + unnegatedClaim( + /null[^.!?]{0,90}(?:ohne|fehlend)[^.!?]{0,45}dns[^.!?]{0,70}(?:ablehn|lehnt[^.!?]{0,35}ab|ungueltig|ungültig)|provider[^,.;!?]{0,30}(?:liest|reads)[^.!?]{0,80}sna[^.!?]{0,20}/g, + ) + ) + failures.push("corporate_ipv4_failure_condition_inverted"); + if ( + /provider[- ]?upgrade[^.!?]{0,70}(?:behob|gelöst|geloest|fix)|(?:provider[^.!?]{0,45}(?:vor|before)\s+0\.114)|(?:älteren versionen des providers|aelteren versionen des providers)[^.!?]{0,60}0\.114|(?:accelerator\s*\(version|accelerator\s*\(ab version)[^.!?]{0,20}0\.114/.test( + text.replace( + /nicht\s+durch\s+(?:ein(?:en)?\s+)?provider[- ]?upgrade/g, + "", + ), + ) + ) + failures.push("corporate_ipv4_invented_provider_upgrade"); + return failures; +} diff --git a/landing-zone-configurator/app/scripts/prepare-application-project-contract.ts b/landing-zone-configurator/app/scripts/prepare-application-project-contract.ts index 272fd39c..6c5d31c3 100644 --- a/landing-zone-configurator/app/scripts/prepare-application-project-contract.ts +++ b/landing-zone-configurator/app/scripts/prepare-application-project-contract.ts @@ -10,7 +10,7 @@ const target = process.argv[2]; if (!target?.startsWith("/")) throw new Error("Absolute fixture directory required"); if ( - applicationMvpAcceleratorCommit !== "9e7fb46c329aca32e878ce1f0ea3b944ea240354" + applicationMvpAcceleratorCommit !== "1ed855609d4dcbfded6759c7c8b4fabf7bd6725d" ) throw new Error( "Requalify Application project scope for the new Runner source", @@ -41,6 +41,18 @@ const tests = [ mock_resource "stackit_service_account" { defaults = { email = "automation@sa.stackit.cloud" } } + mock_data "stackit_network_area_region" { + defaults = { + ipv4 = { + default_prefix_length = 28 + min_prefix_length = 24 + max_prefix_length = 28 + default_nameservers = ["192.0.2.53"] + transfer_network = "10.254.255.0/24" + network_ranges = [{network_range_id="77777777-2222-4333-8444-555555555555",prefix="10.32.0.0/16"}] + } + } + } mock_resource "stackit_routing_table" { defaults = { routing_table_id = "77777777-2222-4333-8444-555555555555" } } @@ -50,8 +62,13 @@ mock_provider "time" {} ]; const id = "11111111-2222-4333-8444-555555555555"; for (const region of ["eu01", "eu02"]) { - for (const kind of ["public-none", "public-local", "corporate"]) { - const corporate = kind === "corporate"; + for (const kind of [ + "public-none", + "public-local", + "corporate", + "corporate-automatic", + ]) { + const corporate = kind.startsWith("corporate"); for (const observability of [false, true]) { const name = `${region}_${kind.replaceAll("-", "_")}_${observability ? "observability" : "base"}`; const compiled = compileApplicationPlan({ @@ -83,7 +100,7 @@ for (const region of ["eu01", "eu02"]) { apply_policy: "approval-required", env: "dev", network_enabled: kind === "public-local", - network_prefix_length: 24, + network_prefix_length: kind === "corporate-automatic" ? null : 24, parameter_policy: { schema_version: 1, fields: {} }, custom_roles: [], role_assignments: [], @@ -116,9 +133,11 @@ for (const region of ["eu01", "eu02"]) { JSON.stringify(compiled.variables), ); const network = - kind === "public-none" - ? "module.application.project_network == null" - : `module.application.project_network != null && module.application.project_network.routed == ${corporate} && module.application.project_network.ipv4_prefix_length == 24`; + kind === "corporate-automatic" + ? "var.application.network_prefix_length == null && var.platform_contract.targets.selected.corporate" + : kind === "public-none" + ? "module.application.project_network == null" + : `module.application.project_network != null && module.application.project_network.routed == ${corporate} && module.application.project_network.ipv4_prefix_length == 24`; tests.push(`run "compiled_${name}" { command = plan variables { @@ -141,6 +160,19 @@ for (const region of ["eu01", "eu02"]) { condition = module.application.secretsmanager_instance_id != null && (module.application.observability_instance_id != null) == ${observability} error_message = "Compiled optional services must reach the real project module." } +}`); + if (kind === "corporate-automatic") + tests.push(`run "compiled_${name}_resolved" { + # All providers are mocked: exercise the deferred regional read after contract validation. + command = apply + variables { + platform_contract = jsondecode(file("${name}.json")).platform_contract + application = jsondecode(file("${name}.json")).application + } + assert { + condition = module.application.project_network.ipv4_prefix_length == 28 && tolist(var.platform_contract.targets.selected.ipv4_nameservers) == tolist(["192.0.2.53"]) + error_message = "Automatic must resolve the actual SNA prefix without changing configured DNS." + } }`); } } diff --git a/landing-zone-configurator/app/scripts/test-application-contract.sh b/landing-zone-configurator/app/scripts/test-application-contract.sh index f2a5566a..4a408b1a 100644 --- a/landing-zone-configurator/app/scripts/test-application-contract.sh +++ b/landing-zone-configurator/app/scripts/test-application-contract.sh @@ -38,3 +38,7 @@ env -i PATH="$PATH" TF_CLI_CONFIG_FILE=/dev/null TF_IN_AUTOMATION=true TF_INPUT= "$tofu_bin" -chdir="$work_dir/pinned/src/application" init -backend=false -lockfile=readonly -input=false -no-color env -i PATH="$PATH" TF_CLI_CONFIG_FILE=/dev/null TF_IN_AUTOMATION=true TF_INPUT=0 \ "$tofu_bin" -chdir="$work_dir/pinned/src/application" test -no-color + +# Reuse the installed, checksum-locked Runner provider in a credential-free, +# local-only regression: mocks do not run the provider's IPv4 validator. +bash "$app_dir/scripts/test-application-network.sh" "$work_dir/pinned/src/application/.terraform/providers" diff --git a/landing-zone-configurator/app/scripts/test-application-network.sh b/landing-zone-configurator/app/scripts/test-application-network.sh new file mode 100644 index 00000000..778af431 --- /dev/null +++ b/landing-zone-configurator/app/scripts/test-application-network.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Real pinned provider, fresh local state, synthetic input, plan only. +# Provider mocks bypass the IPv4 ValidateConfig check this regression exercises. +set -euo pipefail +umask 077 +app_dir="$(cd "$(dirname "$0")/.." && pwd)" +tofu_bin="${LZC_TEST_TOFU_BIN:?Set LZC_TEST_TOFU_BIN to the pinned OpenTofu executable}" +case "$tofu_bin" in /*) ;; *) echo 'Absolute OpenTofu path required.' >&2; exit 1 ;; esac +provider_dir="${1:-}" +if [[ -n "$provider_dir" ]]; then + case "$provider_dir" in /*) ;; *) echo 'Absolute provider directory required.' >&2; exit 1 ;; esac + [[ -d "$provider_dir" ]] || { echo 'Provider directory does not exist.' >&2; exit 1; } +fi +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/lzc-application-network.XXXXXXXX")" +trap 'rm -rf "$work_dir"' EXIT +# Keep the exact Runner provider checksums; no unrelated providers are required. +awk '/^provider .*stackitcloud\/stackit/ { copy=1 } copy { print } copy && /^}/ { copy=0 }' \ + "$app_dir/../deploy/runner/application.lock.hcl" > "$work_dir/.terraform.lock.hcl" +[[ -s "$work_dir/.terraform.lock.hcl" ]] || { echo 'Pinned STACKIT lock entry missing.' >&2; exit 1; } +cat > "$work_dir/main.tf" <<'HCL' +terraform { + required_providers { + stackit = { source = "stackitcloud/stackit", version = "0.114.0" } + } +} +provider "stackit" { + default_region = "eu01" + service_account_token = "offline-provider-validation-only" + # Any accidental IaaS request fails locally. There is no customer endpoint/key. + iaas_custom_endpoint = "http://127.0.0.1:1" +} +variable "prefix_length" { + type = number + nullable = true +} +variable "nameservers" { + type = list(string) + nullable = true +} +variable "deferred" { + type = bool + default = false +} +resource "terraform_data" "prefix" { + count = var.deferred ? 1 : 0 + input = 28 +} +resource "stackit_network" "corporate" { + name = "offline-corporate-validation" + project_id = "11111111-2222-4333-8444-555555555555" + routed = true + ipv4_prefix_length = var.deferred ? terraform_data.prefix[0].output : var.prefix_length + ipv4_nameservers = var.nameservers +} +HCL +run_tofu() { + # Never inherit credentials, TF_VAR/CLI arguments, user CLI config or a backend. + env -i PATH="$PATH" TF_CLI_CONFIG_FILE=/dev/null TF_IN_AUTOMATION=true TF_INPUT=0 \ + "$tofu_bin" -chdir="$work_dir" "$@" +} +init_args=(-backend=false -lockfile=readonly -input=false -no-color) +if [[ -n "$provider_dir" ]]; then init_args+=("-plugin-dir=$provider_dir"); fi +run_tofu init "${init_args[@]}" > "$work_dir/init.log" 2>&1 || { cat "$work_dir/init.log" >&2; exit 1; } +# validate sees unknown input values; only a real provider Plan catches null+DNS. +run_tofu validate -no-color > "$work_dir/validate.log" 2>&1 || { cat "$work_dir/validate.log" >&2; exit 1; } +run_case() { + local case_name="$1" prefix="$2" nameservers="$3" expected="$4" deferred="${5:-false}" + local plan_file="$work_dir/$case_name.plan" log_file="$work_dir/$case_name.log" + printf '{"prefix_length":%s,"nameservers":%s,"deferred":%s}\n' "$prefix" "$nameservers" "$deferred" > "$work_dir/values.tfvars.json" + if run_tofu plan -refresh=false -lock=false -input=false -no-color \ + "-var-file=$work_dir/values.tfvars.json" "-out=$plan_file" > "$log_file" 2>&1; then + if [[ "$expected" != success ]]; then + echo "Expected Invalid IPv4 configuration: $case_name" >&2; exit 1 + fi + run_tofu show -json "$plan_file" > "$work_dir/plan.json" + node --input-type=module - "$work_dir/plan.json" "$prefix" "$deferred" <<'JS' +import { readFileSync } from 'node:fs'; +import assert from 'node:assert/strict'; +const plan = JSON.parse(readFileSync(process.argv[2], 'utf8')); +assert.equal(plan.resource_changes.length, process.argv[4] === "true" ? 2 : 1); +const network = plan.resource_changes.find(entry => entry.type === "stackit_network"); +assert.equal(network.type, 'stackit_network'); +assert.deepEqual(network.change.actions, ['create']); +assert.equal(network.change.after.routed, true); +if (process.argv[4] === "true") + assert.equal(network.change.after_unknown.ipv4_prefix_length, true); +else if (process.argv[3] !== 'null') + assert.equal(network.change.after.ipv4_prefix_length, Number(process.argv[3])); +JS + else + if [[ "$expected" != invalid ]]; then cat "$log_file" >&2; exit 1; fi + if ! rg -q '^Error: Invalid IPv4 configuration$' "$log_file" || + ! rg -q "must provide either 'ipv4_prefix' or" "$log_file"; then + cat "$log_file" >&2; exit 1 + fi + fi + echo "Real STACKIT network provider: $case_name passed ($expected)." +} +run_case missing_prefix_with_dns null '["192.0.2.53"]' invalid +run_case missing_prefix_with_empty_dns null '[]' invalid +run_case inherited_sna_prefix_28 28 '["192.0.2.53"]' success +run_case explicit_prefix_24 24 '["192.0.2.53"]' success +run_case explicit_prefix_26 26 '["192.0.2.53"]' success +run_case automatic_without_dns null null success +run_case deferred_sna_prefix null '["192.0.2.53"]' success true diff --git a/landing-zone-configurator/app/tests/assistant-scenarios.test.ts b/landing-zone-configurator/app/tests/assistant-scenarios.test.ts index 161d11cc..e514d5a5 100644 --- a/landing-zone-configurator/app/tests/assistant-scenarios.test.ts +++ b/landing-zone-configurator/app/tests/assistant-scenarios.test.ts @@ -9,6 +9,7 @@ import { } from "../apps/api/src/assistant/knowledge.js"; import { buildAssistantTopicFieldPaths } from "../apps/api/src/assistant/topic-fields.js"; import { + assistantCorporateIpv4Failures, assistantDevOpsDefaultFailures, assistantDnsRoleFailures, assistantScenarioFixture, @@ -267,11 +268,13 @@ it.each(assistantScenarios)( expect( validateAssistantScenario(scenario, { explanation: - scenario.topic === "vpn" - ? buildAssistantKnowledge().topics.find( - (topic) => topic.id === "vpn", - )?.fieldOverview - : `Nur Beratung, keine Speicherung oder Ausfuehrung. ${scenario.requiredOverviewFields?.join(", ") ?? ""}. Namen 1-40 Zeichen; Beschreibungen nicht unterstützt und abgelehnt. pass, block, reject; explizite Synchronisierung. ${scenario.dnsRole ? dnsRoleExplanation : ""} git_flavor=null erstellt keine Git-Instanz; git-10 ist kein automatisch aktivierter Standard.`, + scenario.corporateIpv4 === "advice" + ? "Automatisch übernimmt das Standardpräfix /28 der SNA. Der Provider benötigt bei gesetztem DNS ein explizites Präfix; das native Modul liest es im Plan aus der SNA und übergibt es explizit. DNS bleibt erhalten. Freie Kapazität ist nicht garantiert." + : scenario.topic === "vpn" + ? buildAssistantKnowledge().topics.find( + (topic) => topic.id === "vpn", + )?.fieldOverview + : `Nur Beratung, keine Speicherung oder Ausfuehrung. ${scenario.requiredOverviewFields?.join(", ") ?? ""}. Namen 1-40 Zeichen; Beschreibungen nicht unterstützt und abgelehnt. pass, block, reject; explizite Synchronisierung. ${scenario.dnsRole ? dnsRoleExplanation : ""} git_flavor=null erstellt keine Git-Instanz; git-10 ist kein automatisch aktivierter Standard.`, questions: [], changes: [], references: [scenario.topic], @@ -680,3 +683,116 @@ it("checks exact SNA values and rejects missing or unrelated settings", () => { validateAssistantScenario(scenario, { ...input, changes: [] }).failures, ).toContain("sna_values_not_proposed_exactly"); }); + +it("requires factual Automatic/DNS advice and rejects fixed-size or DNS-removal claims", () => { + const correct = + "Automatisch übernimmt das Standardpräfix /28 der SNA. Der Provider benötigt bei gesetztem DNS ein explizites Präfix; das native Modul liest es im Plan aus der SNA und übergibt es explizit. DNS bleibt erhalten. Freie Kapazität ist nicht garantiert."; + expect(assistantCorporateIpv4Failures(correct)).toEqual([]); + expect( + assistantCorporateIpv4Failures( + `${correct} Du musst nicht /24 setzen. DNS muss nicht abgeschaltet werden. Der Provider liest den SNA-Default nicht.`, + ), + ).toEqual([]); + expect( + assistantCorporateIpv4Failures( + "Automatisch /28 SNA Standard DNS Provider explizites Präfix", + ), + ).not.toEqual([]); + expect( + assistantCorporateIpv4Failures(`${correct} Du musst /24 setzen.`), + ).toContain("corporate_ipv4_invented_fixed_size_or_dns_removal"); + expect( + assistantCorporateIpv4Failures(`${correct} DNS muss abgeschaltet werden.`), + ).toContain("corporate_ipv4_invented_fixed_size_or_dns_removal"); +}); + +it("accepts numeric-prefix wording and DNS with an IP while rejecting inverted cause/reader claims", () => { + const correct = + "Automatisch liest das Corporate-Modul den SNA-Standard /28 und übergibt einen numerischen Präfix an den Provider. Der DNS-Eintrag (hier: 1.1.1.1) bleibt unverändert erhalten. Freie Kapazität ist nicht garantiert."; + expect(assistantCorporateIpv4Failures(correct)).toEqual([]); + expect( + assistantCorporateIpv4Failures( + `${correct} Der Provider liest den SNA-Default.`, + ), + ).toContain("corporate_ipv4_failure_condition_inverted"); + expect( + assistantCorporateIpv4Failures( + `${correct} Null ohne DNS lehnt der Provider ab.`, + ), + ).toContain("corporate_ipv4_failure_condition_inverted"); +}); + +it("rejects attributing the native sizing fix to a provider upgrade", () => { + const base = + "Automatisch übernimmt das Standardpräfix /28 der SNA. Das Modul übergibt dem Provider den numerischen Präfix mit unveränderten DNS-Servern. Freie Kapazität ist nicht garantiert."; + expect(assistantCorporateIpv4Failures(base)).toEqual([]); + expect( + assistantCorporateIpv4Failures( + `${base} Dieser Fehler wurde nicht durch einen Provider-Upgrade, sondern durch eine Korrektur im Accelerator-Modul behoben.`, + ), + ).toEqual([]); + expect( + assistantCorporateIpv4Failures( + `${base} Vor und nach dem Modul-Fix bleibt der Provider bei 0.114.0. Im korrigierten Accelerator bleibt Provider 0.114.0 unverändert.`, + ), + ).toEqual([]); + expect( + assistantCorporateIpv4Failures( + `${base} Der Provider vor 0.114.0 scheiterte; ein Provider-Upgrade hat den Fehler behoben.`, + ), + ).toContain("corporate_ipv4_invented_provider_upgrade"); +}); + +it.each(["automatic", "explicit"] as const)( + "checks Corporate %s proposals preserve the full draft and exact prefix", + (mode) => { + const scenario = assistantScenarios.find( + (entry) => entry.id === `corporate-ipv4-${mode}-proposal`, + ); + if (!scenario) throw new Error("scenario_missing"); + const prefix = mode === "automatic" ? null : 26; + const dialogue = { + explanation: + "Überprüfbarer Vorschlag für die angeforderte Netzgröße. Noch nichts gespeichert.", + questions: [], + references: ["project-templates"], + changes: [ + { + path: "projectTemplates", + operation: "merge", + value: { central: { settings: { network_prefix_length: prefix } } }, + }, + ], + }; + expect(validateAssistantScenario(scenario, dialogue).failures).toEqual([]); + expect( + validateAssistantScenario(scenario, { + ...dialogue, + changes: [ + { + ...dialogue.changes[0], + value: { central: { settings: { network_prefix_length: 24 } } }, + }, + ], + }).failures, + ).toContain("corporate_ipv4_proposal_incorrect_or_unrelated_change"); + expect( + validateAssistantScenario(scenario, { + ...dialogue, + changes: [ + { + ...dialogue.changes[0], + value: { + central: { + settings: { + network_prefix_length: prefix, + secretsmanager_enabled: false, + }, + }, + }, + }, + ], + }).failures, + ).toContain("corporate_ipv4_proposal_incorrect_or_unrelated_change"); + }, +); diff --git a/landing-zone-configurator/app/tests/platform-worker.test.ts b/landing-zone-configurator/app/tests/platform-worker.test.ts index 96ae240d..f89ffb07 100644 --- a/landing-zone-configurator/app/tests/platform-worker.test.ts +++ b/landing-zone-configurator/app/tests/platform-worker.test.ts @@ -201,7 +201,7 @@ if (phase === "plan") { 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)} === "plan-failure") { - console.log(JSON.stringify({type:"diagnostic", "@message":"Error: Corporate plan fixture failure " + ${JSON.stringify(secret)}})); + console.log(JSON.stringify({type:"diagnostic", "@message":"Error: Corporate plan fixture failure " + ${JSON.stringify(secret)}, diagnostic:{detail:"Provide ipv4_prefix_length " + ${JSON.stringify(secret)}, snippet:{code:"private-diagnostic-snippet"}}})); process.exit(1); } if (${JSON.stringify(behavior)} === "live-output") { @@ -772,6 +772,14 @@ it("retains a redacted failed Application plan diagnostic before reporting failu text: expect.stringContaining("Error: Corporate plan fixture failure"), truncated: false, }); + expect(test.reports[outputIndex]?.body).toMatchObject({ + text: expect.stringContaining( + "Provide ipv4_prefix_length (sensitive value)", + ), + }); + expect(JSON.stringify(test.reports[outputIndex]?.body)).not.toContain( + "private-diagnostic-snippet", + ); expect(JSON.stringify(test.reports[outputIndex]?.body)).not.toContain(secret); expect(JSON.stringify(test.reports[outputIndex]?.body)).not.toContain(ticket); expect(outputIndex).toBeLessThan(resultIndex); diff --git a/landing-zone-configurator/app/tests/runner-progress.test.ts b/landing-zone-configurator/app/tests/runner-progress.test.ts index 0249a147..f0437397 100644 --- a/landing-zone-configurator/app/tests/runner-progress.test.ts +++ b/landing-zone-configurator/app/tests/runner-progress.test.ts @@ -45,3 +45,28 @@ it.each([ expect(result.text).not.toContain("private-value"); }, ); + +it("includes human diagnostic details without exposing structured snippets or expression values", () => { + const output = openTofuProgress( + [ + version, + JSON.stringify({ + type: "diagnostic", + "@message": "Error: Invalid IPv4 configuration", + diagnostic: { + detail: + "When IPv4 is configured, provide ipv4_prefix or ipv4_prefix_length.", + snippet: { + code: "private-snippet-value", + values: [{ value: "private-expression-value" }], + }, + address: "private-address", + }, + }), + ].join("\n"), + ); + expect(output.text).toContain("Error: Invalid IPv4 configuration"); + expect(output.text).toContain("provide ipv4_prefix or ipv4_prefix_length"); + expect(output.invalid).toBe(false); + expect(output.text).not.toContain("private-"); +}); diff --git a/landing-zone-configurator/deploy/runner/package.sh b/landing-zone-configurator/deploy/runner/package.sh index 9ade77c9..14b1a337 100644 --- a/landing-zone-configurator/deploy/runner/package.sh +++ b/landing-zone-configurator/deploy/runner/package.sh @@ -62,7 +62,7 @@ test ! -e "$runner_dir/accelerator/terraform.auto.tfvars" test -z "$(find "$runner_dir/accelerator" -type f \( -name '*.tfstate' -o -name '*.tfstate.*' -o -name '.terraform.tfstate.lock.info' -o -name '*.tfplan' -o -name 'plan.bin' -o -name 'saved-plan.bin' -o -name 'credential.json' -o -name '*.log' \) -print -quit)" if [[ "${LZC_PACKAGE_APPLICATION_ROOT:-false}" == "true" ]]; then mkdir -p "$runner_dir/application-src" - application_commit=9e7fb46c329aca32e878ce1f0ea3b944ea240354 + application_commit=1ed855609d4dcbfded6759c7c8b4fabf7bd6725d if [[ "${LZC_HOSTED_EXECUTION_PACKAGE:-false}" == "true" ]]; then git fetch --no-tags https://github.com/stackitcloud/stackit-landing-zone.git "$application_commit" fi diff --git a/landing-zone-configurator/docs/assistant-feature-coverage.json b/landing-zone-configurator/docs/assistant-feature-coverage.json index 7cb4593e..a759b32d 100644 --- a/landing-zone-configurator/docs/assistant-feature-coverage.json +++ b/landing-zone-configurator/docs/assistant-feature-coverage.json @@ -12,7 +12,7 @@ "packages/domain/src/observability-plans.ts": "04b12f2744c453ff44f7b386ba6d6b7052b53f66fc483d46e9d5af0f5f75bbe5", "packages/domain/src/common-document.ts": "64e00aa276f0186b9d4fa70ae88ec13703766a317f1317cbfd6bab790673cb40", "packages/domain/src/editor-document.ts": "b807d40f04b7739cab026d43a412ed9252d22cb9bd1034fbad858825308c1345", - "packages/domain/src/application-catalogue.ts": "eceeaa060c73df05a23a8e588a20e6416786f589d5a9ff979ebd2e737b0594d8", + "packages/domain/src/application-catalogue.ts": "6a36aab7d4cbc918c646a8ced8092bb9ce39571d72aae8e9611df3bffb767507", "packages/domain/src/application-plan.ts": "748832e7c2284bba61b11f194164c8f513225010d18571f283ee7b72723bf302" }, "groups": [ diff --git a/landing-zone-configurator/docs/assistant-knowledge/manifest.json b/landing-zone-configurator/docs/assistant-knowledge/manifest.json index 500175a1..79a5753d 100644 --- a/landing-zone-configurator/docs/assistant-knowledge/manifest.json +++ b/landing-zone-configurator/docs/assistant-knowledge/manifest.json @@ -500,8 +500,8 @@ { "id": "project-templates", "file": "project-templates.md", - "version": 1, - "checkedOn": "2026-10-10", + "version": 2, + "checkedOn": "2026-10-22", "qualification": "local-draft-not-reviewed-release", "groups": [ "projects" @@ -521,9 +521,13 @@ "src/main.tf", "landing-zone-configurator/app/packages/domain/src/common-document.ts", "landing-zone-configurator/app/packages/domain/src/project-templates.ts", - "landing-zone-configurator/app/packages/domain/src/common-validation.ts" + "landing-zone-configurator/app/packages/domain/src/common-validation.ts", + "src/modules/landing-zone/3-network.tf", + "landing-zone-configurator/app/packages/domain/src/application-plan.ts" + ], + "externalSources": [ + "https://github.com/stackitcloud/terraform-provider-stackit/blob/v0.114.0/stackit/internal/services/iaas/network/resource.go" ], - "externalSources": [], "scenarios": [ "A04", "A10", diff --git a/landing-zone-configurator/docs/assistant-knowledge/project-templates.de.md b/landing-zone-configurator/docs/assistant-knowledge/project-templates.de.md index bff232cf..57d94e91 100644 --- a/landing-zone-configurator/docs/assistant-knowledge/project-templates.de.md +++ b/landing-zone-configurator/docs/assistant-knowledge/project-templates.de.md @@ -25,6 +25,16 @@ Public-Instanzen setzen explizit `corporate=false`. Plattformentwuerfe verwenden `projectTemplates` und Parameterrichtlinien, keine persoenlichen Instanzfelder oder erfundene Veroeffentlichungs-/Bestell-Terraform-Attribute. +Corporate-Anwendungsnetze uebernehmen `ipv4_nameservers` vom gebundenen +Plattformziel. STACKIT-Provider 0.114.0 lehnt Null-Praefix plus gesetztes DNS, +auch eine leere Liste, trotz erfolgreichem Validate mit +„Invalid IPv4 configuration“ ab. +Automatische Netzgroesse belaesst `network_prefix_length=null` im Template: +Das native Corporate-Modul liest im Plan/Apply `default_prefix_length` der +angebundenen regionalen SNA und uebergibt das effektive Praefix an den Provider, +bei unveraendertem DNS. Ein explizites Template-Praefix ueberschreibt den +SNA-Default und darf nicht still ersetzt werden. Public verwendet weiterhin die +automatische Providerwahl ohne Corporate-SNA-Abfrage. @@ -59,6 +69,23 @@ Nicht betroffene Templates nicht ohne Zustimmung entfernen. eine Template-Quote. Aus effektiven Praefixen berechnen und Plattform-/Firewall-/Zukunftsbereiche reservieren. +Bei diesem IPv4-Fehler die DNS-/Praefix-Abhaengigkeit und den korrigierten +nativen Automatikpfad erklaeren. Automatisch uebernimmt den tatsaechlichen +Default der angebundenen SNA, setzt weder pauschal /24 noch schaltet es DNS ab. +Ein SNA-Default im Entwurf beschreibt Absicht, keinen Nachweis des aktuellen +Cloud-Defaults oder freier Adressen; die Abfrage findet im Plan statt. +Bei ausdruecklichem Automatisch nicht nach einem Zahlenpraefix fragen. +Bestehende explizite Praefixe erhalten. Fuer angeforderte Abweichungen bei Bedarf +Adressbedarf und bekannte Poolgrenzen klaeren; /24 ist ein Beispiel, keine +Eignungs- oder Verfuegbarkeitsgarantie. Bei ausdruecklichem /26 +`settings.network_prefix_length=26` fuer das bestehende Template vorschlagen, +Identitaet und andere Einstellungen erhalten. Fuer ausdrueckliches Automatisch +`settings.network_prefix_length=null` vorschlagen, keinen festen Ersatzwert. +DNS nicht abschalten, nicht zu Public wechseln und keinen State loeschen. +Veroeffentlichte Versionen/Bestellungen behalten ihre unveraenderliche Quelle und +Einstellungen. Fuer die native Korrektur eine neue Version mit korrigierter +Runner-Revision veroeffentlichen und diese bestellen; der Assistant kann nicht +fuer den Benutzer veroeffentlichen/bestellen/Plan/Apply ausfuehren. @@ -67,6 +94,11 @@ reservieren. Public-Instanzen verwenden `corporate=false`. `network_prefix_length=24` ueberschreibt einen SNA-/25-Default. Dies sind Fragmente, keine automatischen Projekterstellungsanfragen. +Corporate-SNA-Default /28 und Automatisch: null im Template erhalten; das +korrigierte native Modul loest /28 fuer den Provider auf und erhaelt Ziel-DNS. +Bestaetigtes /26 ueberschreibt /28; der ausdrueckliche Wechsel zurueck auf +Automatisch schlaegt null vor. Beides garantiert weder freie Kapazitaet noch +einen erfolgreichen Cloud-Apply. @@ -93,6 +125,9 @@ externe Ausfuehrung benoetigt explizite Bindungen. ## Quellen Root-/Landing-Zone-Variablen und gemeinsame Template-/Compiler-/ -Validierungsvertraege. +Validierungsvertraege sowie native Netzverdrahtung. Die Netzwerkressource des +gepinnten Providers validiert die IPv4-Kombination; ein isolierter Plan-Test mit +dem echten Provider prueft leeres/gesetztes DNS, explizite Praefixe und +automatische Groessenwahl ohne DNS. Policy-/Identitaets-/Exportaenderungen erneut pruefen; Anwendungslebenszyklus bleibt getrennt. diff --git a/landing-zone-configurator/docs/assistant-knowledge/project-templates.md b/landing-zone-configurator/docs/assistant-knowledge/project-templates.md index 698622cc..a8ecefb6 100644 --- a/landing-zone-configurator/docs/assistant-knowledge/project-templates.md +++ b/landing-zone-configurator/docs/assistant-knowledge/project-templates.md @@ -2,55 +2,58 @@ ## Purpose -Reusable Public/Corporate project foundations, not application provisioning. +Reusable Public/Corporate foundations, not provisioning. ## Accelerator Contract -Native landing_zones supports corporate, region, network_area_key, project_code, -env, owner_email, network_enabled/network_prefix_length, role_assignments, -custom_roles, observability and secretsmanager_enabled. Public instances explicitly -set corporate=false. Platform drafts use projectTemplates and parameter policies, -not personal instance fields or invented publication/order Terraform attributes. +landing_zones: corporate, region, area, project_code/env/owner_email, network +enabled/prefix, roles, observability, secretsmanager. Public: corporate=false. +Platform drafts use projectTemplates/policies, not personal instance fields +or invented Terraform publication/order attributes. +Automatic keeps network_prefix_length=null. Corrected Corporate module reads +the attached regional SNA default_prefix_length during Plan and supplies it to +the provider, retaining DNS. Explicit prefixes override it. Public automatic +sizing is provider-managed, without a Corporate SNA read. +Provider0.114.0: null prefix+DNS (even []) fails Plan (not necessarily validate): +"Invalid IPv4 configuration". Fix: resolve effective prefix. ## Dependencies -Governance/identity, same-region SNA for Corporate and separate credentials. -Public is not a Corporate spoke. DNS, Secrets Manager and observability are separate. +Governance/identity, same-region SNA for Corporate, separate credentials. +Public is no Corporate spoke; services are separate. ## Dialogue -Template setup is draft, including a switch after Audit/Git. New entries require -name, kind, region and settings; existing entries support partial updates with -stable keys/identities retained. Delegated naming means choose business names, -not another naming question. Central-network versus Internet-only means Corporate -versus Public; requested local networks set network_enabled=true for both. -Reuse saved region, unambiguous same-region area and native subnet defaults. -Merge new templates; omit unrequested services, not inherited platform Audit/Git. -Ask about missing/ambiguous choices and requested application-owner policies. -No sandboxes means no sandbox creation, not skipping Public/Corporate setup. -Do not remove unrelated templates without consent. "100 projects" is capacity, -not 100 generated examples or a template quota. Compute from effective prefixes -and reserve platform/firewall/future ranges. +Draft setup, also after Audit/Git: new entries need name/kind/region/settings; +updates retain identities. Delegated naming means choose names. +Central versus Internet-only means Corporate versus Public; local networks set +network_enabled=true. Reuse saved region/unambiguous area and existing prefixes. +Merge additions; omit unrequested services, retain platform Audit/Git. +Ask missing choices/policies. No sandboxes does not skip projects; removals need consent. +"100 projects" is capacity, not generated examples/quota. Calculate effective +prefixes with platform/firewall/future reservations; no free-capacity guarantee. +Automatic means inherit actual SNA default, never fixed /24 or disabled DNS. +Draft defaults are not live evidence. Do not demand numbers for Automatic. +Explicitly requested /26 proposes settings.network_prefix_length=26; switching +back to Automatic proposes null. Preserve other settings/identity. Ask capacity/pool constraints for undecided overrides only. ## Examples -Public instances use corporate=false. network_prefix_length=24 overrides an SNA -/25 default. These are fragments, not automatic project creation requests. +SNA default /28 + Automatic: null in template, effective /28 to provider, DNS +retained. Confirmed /26 overrides it. /24 is an example, not universally suitable. ## Limits -Reviewed native settings/policies can be proposed, not published, ordered or -provisioned. Region, DNS/area and namespace references must match. Empty roles/ACLs -are not least-privilege proof. Quotas/usable addresses need live checks; theoretical -subnet counts are not guarantees. +Propose only; never publish/order/Plan/Apply. Native fix needs corrected Runner +revision/new version/order; old immutable versions retain source/settings. Never +delete State. Region/DNS/area/namespace must match; empty roles/ACLs prove no security. +Quotas/usable addresses need live checks; subnet arithmetic is no guarantee. ## Credentials -Provisioning, delegated-owner credentials, workload access and runtime grants are -separate. No secrets/profile IDs in model context or exports; external execution -needs explicit bindings. +Separate access/runtime grants. No secrets/profile IDs in context/exports; +execution needs explicit external bindings. ## Sources -Root/landing-zone variables and common template/compiler/validation contracts. -Re-review policy/identity/export changes; application lifecycle remains separate. \ No newline at end of file +Native wiring, template/compiler contracts, pinned IPv4 validator; re-review changes. diff --git a/landing-zone-configurator/docs/assistant-test-catalogue.json b/landing-zone-configurator/docs/assistant-test-catalogue.json index 97528762..77ea1124 100644 --- a/landing-zone-configurator/docs/assistant-test-catalogue.json +++ b/landing-zone-configurator/docs/assistant-test-catalogue.json @@ -2,12 +2,12 @@ "schemaVersion": 1, "status": "field-inventory-and-smoke-corpus-not-ai-qualification", "engineRevision": "a256f6896d11134fdc351786f1be5eba4e56b2e2", - "knowledgeSha256": "436435106841a476ec0c7cf7a948f0f94e9ac99566ebfdb98838980fe45f3738", + "knowledgeSha256": "06c2a2b9a86b1120b33aa29da0999d9831c534a9de1e95e63c4d2497be672b0e", "sourceDigests": { - "scripts/assistant-scenarios.ts": "d9d361ddc0da9d928abeedfa85e6da189f3fed25aa581ca7264723ac85ac24a9", + "scripts/assistant-scenarios.ts": "89bf733e05cc0d073287064bab130dce46e1465a7a64c68592db64de4199fa75", "scripts/assistant-tests.ts": "5e19b1c74da1ffa996f2d76876cc8dc639da698d0b6f9b7e1fc6637863833a8d" }, - "suiteSha256": "a186d02cfe1f3c0aa0b82d058006dbb1029b2e4b07020e0e934db478354f19b9", + "suiteSha256": "c82eeec4a5972b70c7287a565efae9ea149110feeba6f7fc87728ec2fa685b4d", "topics": [ { "id": "credential-foundations", @@ -5269,6 +5269,46 @@ } ], "cases": [ + { + "id": "corporate-ipv4-automatic-advice", + "topic": "project-templates", + "fixture": "baseline", + "corporateIpv4": "advice", + "message": "Unser Corporate-Template nutzt Automatisch, die angebundene SNA hat Standardpräfix /28 und DNS-Server. Erkläre, wie Automatisch im korrigierten Accelerator funktioniert und warum zuvor Invalid IPv4 configuration kam. Muss ich zwingend /24 setzen oder DNS ausschalten? Nur beraten, nichts ändern; keine freien Adressen behaupten.", + "assertions": [ + "valid_dialogue", + "reviewed_topic_reference", + "no_changes" + ] + }, + { + "id": "corporate-ipv4-explicit-proposal", + "topic": "project-templates", + "fixture": "corporate-ipv4", + "corporateIpv4": "explicit", + "message": "Ändere nur die Netzgröße der bestehenden Corporate-Vorlage central auf ausdrücklich /26. Der Adressbedarf und die SNA-Grenzen sind dafür geprüft. Schlüssel, Name, Dienste und DNS unverändert lassen. Nur überprüfbarer Editor-Vorschlag, keine Veröffentlichung oder Ausführung.", + "assertions": [ + "valid_dialogue", + "reviewed_topic_reference", + "compiler_accepts_proposal", + "explicit_unsaved_notice", + "no_premature_saved_claim" + ] + }, + { + "id": "corporate-ipv4-automatic-proposal", + "topic": "project-templates", + "fixture": "corporate-ipv4", + "corporateIpv4": "automatic", + "message": "Setze nur die Netzgröße der bestehenden Corporate-Vorlage central von /26 zurück auf Automatisch, damit der Standard der angebundenen SNA verwendet wird. Keinen festen Ersatzwert setzen, DNS und alle anderen Einstellungen erhalten. Nur überprüfbarer Editor-Vorschlag, keine Veröffentlichung oder Ausführung.", + "assertions": [ + "valid_dialogue", + "reviewed_topic_reference", + "compiler_accepts_proposal", + "explicit_unsaved_notice", + "no_premature_saved_claim" + ] + }, { "id": "platform-public-corporate-networks", "topic": "project-templates", diff --git a/landing-zone-configurator/docs/user-guide.de.md b/landing-zone-configurator/docs/user-guide.de.md index a508d45a..f5186216 100644 --- a/landing-zone-configurator/docs/user-guide.de.md +++ b/landing-zone-configurator/docs/user-guide.de.md @@ -111,7 +111,28 @@ mit älteren Runner-Quellen behalten ihren früheren Ausführungsumfang. Jede Be verwendet einen eigenen State-Schlüssel; Veröffentlichung und Bestellung geben keinen Infrastruktur-Apply frei. + + +### IPv4-Netzgröße für Corporate-Projekte + +**Netzgröße (IPv4-Präfixlänge)** für **Automatisch** leer lassen. Corporate-Netze +übernehmen das Standardpräfix ihrer angebundenen regionalen SNA: Das native Modul +liest es im Plan/Apply und übergibt es bei unverändertem DNS explizit an den +STACKIT-Provider. Bei Public übernimmt weiterhin der Provider die automatische +Größenwahl. Ein explizites Präfix überschreibt den Default; Kapazität und Plan vor +Apply prüfen. Automatisch garantiert keine freien Adressen. + +Die Korrektur benötigt eine neue Template-Version mit korrigierter Runner-Revision. +Bestehende Versionen und Bestellungen behalten ihre unveränderliche Quelle und +Einstellungen; aus der korrigierten Version eine neue Bestellung erstellen. +Bestehender State wird weder zurückgesetzt noch verschoben. Ein fehlgeschlagener +Plan legt keine Cloud-Ressourcen an. Lesbare Providerdiagnosen enthalten ihre +Erklärung, ohne strukturierte Snippets oder Ausdrücke weiterzugeben. +Der Assistant erklärt die Vererbung und schlägt für angefordertes Automatisch +null oder den explizit gewünschten Zahlenwert vor, ohne Veröffentlichung/Ausführung. + + ## Plan und Apply Die Bestelldetails zeigen **Ausführungslogs** in den Schritten **Plan**, **Drift** @@ -157,3 +178,23 @@ dass private Endpunkte während Apply erreichbar sind. Siehe [MVP-Umfang und Grenzen](mvp-scope.de.md) für bewusste Ausschlüsse und [Architektur](architecture.de.md) für Komponenten und State-Grenzen. + + + +## Fehlende Bestelleridentität nach dem Login ergänzen + +Eine Bestellung kann bereits angelegt sein, bevor ein gültiger persönlicher +STACKIT-Anmeldenachweis vorliegt. Nach der Anmeldung kann die bestellende Person +die Plan-Eingaben derselben Bestellung prüfen. Fehlte die verifizierte +Bestellerbindung und hat die Bestellung noch keine Freigabe und keinen Job, +ergänzt der Server diese Bindung einmalig und leitet ausschließlich die im Template +vorgesehenen Besteller-Kontextrollen ab. Ursprüngliche Einstellungen und Ergänzung +bleiben als unveränderlicher Nachweis erhalten. Bestell-ID, Template/Version, +Parameter und State-Schlüssel bleiben gleich; dabei läuft kein Cloud-Plan oder +Apply. Eine doppelte Bestellung ist nicht erforderlich. + +Eine andere E-Mail bei einer bereits gebundenen Bestellung bleibt gesperrt. +Mit der ursprünglich verwendeten Identität anmelden. Bestellungen mit Freigaben +oder Ausführungsnachweisen werden nicht automatisch umgebunden; der Platform Owner +soll die bestehende Bindung und die Nachweise prüfen. State und Bestellung werden +dabei weder stillschweigend ersetzt noch zurückgesetzt. diff --git a/landing-zone-configurator/docs/user-guide.md b/landing-zone-configurator/docs/user-guide.md index 356cd859..045d3615 100644 --- a/landing-zone-configurator/docs/user-guide.md +++ b/landing-zone-configurator/docs/user-guide.md @@ -99,6 +99,23 @@ bindings remain blocked until their effective egress source is qualified. Existi versions on older Runner sources retain their previous execution scope. Each order uses its own State key; publication and ordering do not approve an infrastructure Apply. +### Corporate IPv4 network size + +Leave **Network size (IPv4 prefix length)** empty for **Automatic**. Corporate +networks inherit the default prefix of their attached regional SNA: the native +module reads that value during Plan/Apply and passes it explicitly to the STACKIT +provider while retaining DNS settings. Public automatic sizing remains managed +by the provider. An explicit prefix overrides the default; review capacity and +the resulting Plan before Apply. Automatic does not guarantee free addresses. + +The correction requires a new template version using the corrected Runner +revision. Existing versions and orders keep their immutable source/settings; +create an order from the corrected version. No existing State is reset or moved. +A failed Plan creates no cloud resources. Human-readable provider diagnostics +include their explanation, without forwarding structured snippets or expressions. +The Assistant explains inheritance and proposes null for requested Automatic or +the explicitly requested numeric override, without publishing or executing it. + ## Plan and Apply Application order details show **Execution logs** on the **Plan**, **Drift** and @@ -141,3 +158,19 @@ private endpoints will be reachable during Apply. See [MVP scope and limits](mvp-scope.md) for deliberate exclusions and [architecture](architecture.md) for components and state boundaries. + + +## Complete a missing order identity after login + +An order may be created before its owner's valid STACKIT login proof is available. +After logging in, the owner can check that same order's Plan inputs. If the verified +owner binding was missing and the order has no approval or job yet, the server +completes the binding once and derives only the template's declared owner-context +roles. The original settings and completion are retained in an immutable audit. +The order ID, template/version, parameters and State key stay unchanged; no cloud +Plan or Apply runs as part of this completion. A duplicate order is unnecessary. + +A different email on an already bound order remains blocked. Sign in with the +identity originally used. An order with approval or execution evidence is not +rebound automatically; ask the Platform Owner to review its existing binding and +evidence rather than changing the State or silently replacing the order. From 68aa4236cc2be130b2d06c7184d3466ed377d47c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 22 Oct 2026 09:28:47 +0000 Subject: [PATCH 3/3] fix(ci): use portable grep for provider diagnostic checks --- .../app/scripts/test-application-network.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/landing-zone-configurator/app/scripts/test-application-network.sh b/landing-zone-configurator/app/scripts/test-application-network.sh index 778af431..27c4a140 100644 --- a/landing-zone-configurator/app/scripts/test-application-network.sh +++ b/landing-zone-configurator/app/scripts/test-application-network.sh @@ -89,8 +89,8 @@ else if (process.argv[3] !== 'null') JS else if [[ "$expected" != invalid ]]; then cat "$log_file" >&2; exit 1; fi - if ! rg -q '^Error: Invalid IPv4 configuration$' "$log_file" || - ! rg -q "must provide either 'ipv4_prefix' or" "$log_file"; then + if ! grep -q '^Error: Invalid IPv4 configuration$' "$log_file" || + ! grep -q "must provide either 'ipv4_prefix' or" "$log_file"; then cat "$log_file" >&2; exit 1 fi fi