diff --git a/docs/getting-started.de.md b/docs/getting-started.de.md index 14b04f3d..073ed98c 100644 --- a/docs/getting-started.de.md +++ b/docs/getting-started.de.md @@ -9,6 +9,27 @@ Diese Anleitung führt Sie durch die Bereitstellung der STACKIT Landing Zone von > Die Module in diesem Terraform-Repository sind nicht zur direkten Nutzung vorgesehen. Klonen Sie das Repository und passen Sie die Module an Ihre konkreten Anforderungen an. Es gibt keine Garantien für Migrationspfade oder dafür, dass die Module unverändert bleiben. +## Upgrade von Bucket-Namen und Observability-Einstellungen + +Bucket-Namen gelten STACKIT-weit. Der Standard `bucket_naming = "project-id"` +enthält jetzt die vollständige ID des jeweiligen Management- oder Anwendungsprojekts. +Nur der lesbare Präfix wird auf insgesamt höchstens 63 Zeichen gekürzt. +Die State-Adressen bleiben stabil, Bucket-Namen sind jedoch unveränderlich: +**Vor dem Upgrade einer Installation mit vorhandenen bisherigen Bucket-Namen +`bucket_naming = "legacy"` setzen**, sofern keine separate Ersetzung bzw. +Daten-/Backend-Migration geprüft wurde. Es gibt keine automatische Datenmigration +oder State-Neuanlage. Ein teilweise ausgeführtes Deployment ohne Buckets kann +Projekt und Checkpoint mit dem neuen Standard beibehalten. Vor Apply einen neuen +vollständigen Plan prüfen. +Siehe [Issue #127](https://github.com/stackitcloud/stackit-landing-zone/issues/127). + +Zentrale Observability-Aufbewahrungswerte sind optional/null und verwenden sonst +die Servicestandards. Monitoring unterstützt keine Logs-/Trace-Aufbewahrung; +Frontend und MetricsEndpoint keine Aufbewahrungsüberschreibungen. Explizite Werte +müssen innerhalb der Servicegrenzen liegen (ganze Tage: Logs/Traces 1–30, +Metriken/Downsampling 1–780). Das native Root weist diese unzulässigen Kombinationen +vor der Ressourcenbereitstellung zurück. + ## Voraussetzungen - Eine **STACKIT-Organisation**, in der Ihr Benutzerkonto registriert ist diff --git a/docs/getting-started.md b/docs/getting-started.md index e0b71dee..d600597c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -7,6 +7,25 @@ This guide walks you through deploying the STACKIT Landing Zone from scratch. > [!NOTE] > The modules in this Terraform repository are not intended to be used directly. Clone the repository and adjust the modules to your specific requirements. No guarantees are made about migration paths or that the modules will remain unchanged. +## Upgrading bucket naming and Observability settings + +Bucket names are STACKIT-wide. The default `bucket_naming = "project-id"` now +includes the complete owning project UUID for management and application buckets. +Only the readable prefix is truncated to keep the full name within 63 characters. +Existing bucket resource addresses remain stable, but names are immutable: +**set `bucket_naming = "legacy"` before upgrading an installation that already +owns legacy-named buckets**, unless a separate replacement/data/backend migration +has been reviewed. No data migration or state reset happens automatically. +A partially applied deployment without buckets can keep its original project and +checkpoint and use the new default. Review a fresh full Plan before Apply. +See [issue #127](https://github.com/stackitcloud/stackit-landing-zone/issues/127). + +Central Observability retention overrides are optional/null and otherwise use +service defaults. Monitoring supports no logs/traces retention; Frontend and +MetricsEndpoint support no retention overrides. Explicit values must fit the +service limits (whole days: logs/traces 1–30, metrics/downsampling 1–780). The native +root rejects these incompatible combinations before provisioning resources. + ## Prerequisites - A **STACKIT organization** with your user account registered 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 36ed0ea0..48a23a1f 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 @@ -26,12 +26,12 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/docs/deployment-credentials.md", sha256: - "d517eaa07289b481bb9bca4bb02d489b47dd129a6f8bfee7c96f15e78e62112c", + "e003def890db3ea64fff99593d8dae7d2cb635541bb5eb2be5a71abea04322e2", }, { path: "src/variables.tf", sha256: - "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", }, { path: "src/providers.tf", @@ -89,12 +89,12 @@ export const assistantKnowledgeTopics = { { path: "src/variables.tf", sha256: - "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", }, { path: "landing-zone-configurator/app/packages/domain/src/common-validation.ts", sha256: - "12b1a51aef9055e0146f41fa30f9ba03de0e6e078f78597cc488da9884f0761f", + "27b7edb026926aef1b6efb4f8e2c131d43a37afb2abaa78f0d600006a5f4a492", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -171,7 +171,7 @@ export const assistantKnowledgeTopics = { { path: "src/variables.tf", sha256: - "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", }, { path: "src/providers.tf", @@ -181,7 +181,7 @@ export const assistantKnowledgeTopics = { { path: "src/main.tf", sha256: - "8592367381556c5eb6ae376d4bfe58ff4afe8bb7c37e1b8d8052d7678834d4d9", + "0b2f27c869b6afe2be5614fe8895966739f874f18d1fd8905224735703b249c6", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -241,12 +241,12 @@ export const assistantKnowledgeTopics = { { path: "src/main.tf", sha256: - "8592367381556c5eb6ae376d4bfe58ff4afe8bb7c37e1b8d8052d7678834d4d9", + "0b2f27c869b6afe2be5614fe8895966739f874f18d1fd8905224735703b249c6", }, { path: "landing-zone-configurator/app/packages/domain/src/common-validation.ts", sha256: - "12b1a51aef9055e0146f41fa30f9ba03de0e6e078f78597cc488da9884f0761f", + "27b7edb026926aef1b6efb4f8e2c131d43a37afb2abaa78f0d600006a5f4a492", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -280,23 +280,23 @@ export const assistantKnowledgeTopics = { { id: "management-state", file: "management-state.md", - version: 1, - checkedOn: "2026-10-10", + version: 2, + checkedOn: "2026-10-21", qualification: "local-draft-not-reviewed-release", groups: ["management"], mandatory: false, - inputs: [], + inputs: ["bucket_naming"], dependencies: ["governance-folders"], sources: [ { path: "src/main.tf", sha256: - "8592367381556c5eb6ae376d4bfe58ff4afe8bb7c37e1b8d8052d7678834d4d9", + "0b2f27c869b6afe2be5614fe8895966739f874f18d1fd8905224735703b249c6", }, { path: "src/modules/management/variables.tf", sha256: - "be76927b66e60781b9f83c4b08bf71089c7411761e22ac7064ab2506dd8a6b72", + "1a230ff4236020cce81fe390972f5bed083c0e9f66e401ba707caab0f68059d9", }, { path: "src/modules/management/1-project.tf", @@ -311,7 +311,7 @@ export const assistantKnowledgeTopics = { { path: "src/modules/management/3-object-storage.tf", sha256: - "593c0b4a9f6003d4fc2bb217def5d301d42dc238b3f75e2f7f5fd25d6fe7c21b", + "3adda3f12df344f2b7db40a32f606c755ccd45ab84ed4d5cd6d59174c16c4f3b", }, { path: "src/modules/management/4-service-account.tf", @@ -326,7 +326,17 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/docs/deployment-credentials.md", sha256: - "d517eaa07289b481bb9bca4bb02d489b47dd129a6f8bfee7c96f15e78e62112c", + "e003def890db3ea64fff99593d8dae7d2cb635541bb5eb2be5a71abea04322e2", + }, + { + path: "landing-zone-configurator/app/apps/api/src/deployments/preparations.ts", + sha256: + "a580691734118d33e406407e1884cec03a5d34488df3c42366d2be049754352d", + }, + { + path: "landing-zone-configurator/app/packages/domain/src/initial-plan.ts", + sha256: + "5a5b2fe26d1231d1e2eb866cb82df35720f4cd366043723fba42fe166d7a6197", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -336,18 +346,18 @@ export const assistantKnowledgeTopics = { ], externalSources: [], scenarios: ["A03", "A16", "A23"], - fieldPaths: [], + fieldPaths: ["bucket_naming"], source: "landing-zone-configurator/docs/assistant-knowledge/management-state.md", - text: "# Management Project and State Foundations\n\n## Purpose\n\nProvide the platform's management project, Terraform state storage and base\noperating identities. Distinguish these from optional monitoring services and\nfrom the backend that stores the current Accelerator deployment's own state.\n\n## Accelerator Contract\n\nThe root calls module.management unconditionally beneath the governance\nplatform folder. Ownership, naming, organization and labels are derived from\nroot inputs. Management implementation determines state-bucket and identity\nresources; it has no independent invented management_enabled switch.\nThe module also creates its Secrets Manager foundation; do not equate that\ninstance with every workload's separate secretsmanager_enabled choice. Backend\nbootstrap/configuration is a separate native execution prerequisite, not a\nruntime Configurator dependency embedded into exported configuration.\n\n## Dependencies\n\nGovernance/naming and credential foundations. Observability, audit routing and\nfederation are optional settings attached to management. Their knowledge\ndependencies do not make those services required.\n\n## Dialogue\n\nExplain that \"only SNA first\" scopes the desired configuration change, not a\ntargeted cloud operation: a full native apply still includes mandatory base\nresources and other enabled inputs. Ask about backend ownership, lifecycle,\nretention and operating responsibility without requesting state or passwords.\n\n## Examples\n\nSynthetic baseline: observability = null, audit_logs = null,\nfederated_identity_providers = []. Optional services are disabled but management\nand governance remain part of the root. This is a fragment, not a complete run.\n\n## Limits\n\nA created state bucket does not prove a configured backend, successful state\nmigration, encryption, lock safety or disaster recovery. Never read/pull/mutate\nremote state as a chat action. Plan, apply, destroy, recovery and publication\nhave independent authorization gates; no targeted Terraform workaround.\n\n## Credentials\n\nProvide backend/object-storage and provider credentials through approved\nexternal execution bindings. Keys, passphrases, state contents and plan files\nmust never enter model context or a knowledge file.\n\n## Sources\n\nRoot and management implementation plus deployment-credential documentation\nare fingerprinted in the manifest. Re-review resource/bootstrap changes before\nregeneration; no source snapshot proves operational recovery qualification.", + text: "# Management Project and State Foundations\n\n## Purpose\n\nManagement project, state storage and operating identities; distinguish optional\nmonitoring and the backend of the current Accelerator deployment.\n\n## Accelerator Contract\n\nmodule.management always exists under the governance platform folder; no\nmanagement_enabled switch. Root inputs derive ownership, naming and labels.\nSecrets Manager is a foundation, independent of workload secretsmanager_enabled.\nBackend bootstrap/configuration is a separate execution prerequisite.\n\nbucket_naming defaults to project-id: all management/workload bucket names contain\nthe full owning project UUID and distinct purpose suffixes within 63 characters.\nSTACKIT names are global. Explicit legacy preserves existing bucket names; never\nswitch existing buckets without reviewing replacement/migration. A failed Apply\nwith no buckets can keep its original project/state and use project-id naming.\n\n## Dependencies\n\nGovernance, naming and credentials. Monitoring, audit and federation stay optional.\n\n## Dialogue\n\n“Only SNA first” scopes the draft change, not a targeted cloud operation. Full\nnative Apply includes management and other enabled inputs. Discuss ownership,\nlifecycle and recovery without requesting state or credentials.\n\n## Examples\n\nobservability=null, audit_logs=null, federated_identity_providers=[] disables\noptional services, not management/governance. This is not a complete run.\n\n## Limits\n\nA bucket proves no backend setup, migration, encryption, locking or recovery.\nNever pull/mutate state as a chat action. Plan, Apply, Destroy, recovery and\npublication require separate authorization. No targeted Terraform workaround.\nA source correction may reuse a failed Bootstrap checkpoint only after explicit\nowner reconciliation of its exact state version, with supported pinned provenance\nand no lock, migration, separate recovery or active run. Use a new preparation\nand reviewed Plan; never restart with empty state or retry the consumed Plan.\n\n## Credentials\n\nUse approved external bindings. No keys, passphrases, state or plans in chat.\n\n## Sources\n\nFingerprint root/management and credential docs. Re-review before regeneration;\nsource snapshots do not qualify operational recovery.\n", sha256: - "045b93b1de672aff359064f85fdf5eeae08147cc65fc36dfb1c07ce469eae8f3", + "33139dc2767df839f67b761b2084ad43cb5747693f8ef6b6bb80d6ae18f4d94e", }, { id: "observability", file: "observability.md", - version: 1, - checkedOn: "2026-10-10", + version: 2, + checkedOn: "2026-10-21", qualification: "local-draft-not-reviewed-release", groups: ["management"], mandatory: false, @@ -357,35 +367,58 @@ export const assistantKnowledgeTopics = { { path: "src/variables.tf", sha256: - "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", }, { path: "src/modules/management/variables.tf", sha256: - "be76927b66e60781b9f83c4b08bf71089c7411761e22ac7064ab2506dd8a6b72", + "1a230ff4236020cce81fe390972f5bed083c0e9f66e401ba707caab0f68059d9", }, { path: "src/modules/management/5-observability.tf", sha256: "51e0a6d31608dcf74093b1c5142a200bf13314ca1d017138605f1eb7e3549d74", }, + { + path: "landing-zone-configurator/app/packages/domain/src/observability-plans.ts", + sha256: + "04b12f2744c453ff44f7b386ba6d6b7052b53f66fc483d46e9d5af0f5f75bbe5", + }, + { + path: "landing-zone-configurator/app/apps/api/src/credentials/catalogues.ts", + sha256: + "bbd12b1dfbe5c8c73da327db32ff587d3b675cb3ec1e93cfb3710beb49135d15", + }, { path: "src/modules/landing-zone/variables.tf", sha256: - "e7efc116c2446b631d6faba73ae525627c8494af8d444902f5b775ed971b9096", + "c8ec47c9c00d4b7af5f84025bf5a2b443c99d7d6aa17952f24517697504c0905", }, { path: "src/modules/platform-kubernetes/variables.tf", sha256: "e28b32bc80ca8c3e6a74c12b93318912e30af17f2c7b7bc2d8b37361cf7514e7", }, + { + path: "landing-zone-configurator/app/apps/api/src/assistant/service.ts", + sha256: + "6386fabea8196f11eae86f7ff4684f143f87940bd6addaec8fbd4d569a62b9a0", + }, + { + path: "landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts", + sha256: + "630dd873cfcff63dadc258c41ccc0a63a654059a5d9347f2ef86c66438aed327", + }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", sha256: "b6c19e75d733b369016e33c911526aa1a163affd514e15e8d36c394965b0fcb1", }, ], - externalSources: [], + externalSources: [ + "https://docs.stackit.cloud/products/logging-and-monitoring/observability/reference/service-plans-observability/", + "https://github.com/stackitcloud/stackit-sdk-go/blob/main/services/observability/v1api/model_plan.go", + ], scenarios: ["A03", "A09", "A10", "A24"], fieldPaths: [ "observability", @@ -400,9 +433,9 @@ export const assistantKnowledgeTopics = { ], source: "landing-zone-configurator/docs/assistant-knowledge/observability.md", - text: "# Central and Project Observability\n\n## Purpose\n\nLogs, traces and metrics centrally or per project; scope and retention follow\noperating needs, not template labels.\n\n## Accelerator Contract\n\nobservability=null disables central management monitoring. Project and\nplatform_kubernetes observability have separate nested contracts. Central fields:\nplan_name, acl, logs/traces/metrics and downsampling retention periods. A project\ninstance does not imply the same central service or authorization.\n\n## Dependencies\n\nManagement, credentials, project/cluster, ingestion paths and deliberate ACLs.\nRespect nested scope. Ordinary telemetry does not prove audit logging.\n\n## Dialogue\n\nClarify signals/projects, retention needs, access and dashboard operation.\nSeparate central/project scope. Reuse live plans/catalogues; a native default\nplan name does not prove availability, quotas, prices or cross-region support.\n\n## Examples\n\nmetrics_retention_days=30: propose that choice and the user's plan/ACL. Omit other\nretention overrides to inherit native defaults; do not copy sample logs/traces\nvalues or ask for answered ACLs. Optional retentions are not mandatory decisions.\n\n## Limits\n\nEstimates need measured volume and current limits. Empty/default ACLs are not\nautomatically private or secure. Demos do not prove production coverage.\nTyped native Editor proposals change only the local draft; execution is separate.\n\n## Credentials\n\nSeparate provisioning from ingestion/dashboard credentials. No Grafana\npasswords, service tokens, OTLP secrets or raw customer logs in chat.\n\n## Sources\n\nManagement/project/platform variables and manifest/engine binding. Refresh after\nreview. Live product facts and operational qualification remain separate.", + text: "# Central and Project Observability\n\n## Purpose\n\nChoose signals, scope, retention and dashboard access for operating needs.\n\n## Accelerator Contract\n\nobservability=null disables central management monitoring. Project and\nplatform_kubernetes have separate nested observability contracts. Central fields:\nplan_name, acl and logs/traces/metrics/downsampling retention. Project monitoring\nimplies neither central monitoring nor its authorization.\n\n## Dependencies\n\nManagement, credentials, project/cluster, ingestion and deliberate ACLs.\nTelemetry does not prove audit logging.\n\n## Dialogue\n\nClarify signals/projects, retention and access. Reuse live plan catalogues;\nnative defaults prove no availability, quota, price or cross-region support.\n\n## Examples\n\nFor metrics_retention_days=30, propose that value and the user's plan/ACL.\nOmit other retention overrides; do not copy sample values or repeat answered ACLs.\nAll central retention fields accept null/omission for service defaults.\nFresh live capacities override static families per signal; missing data uses the\nreviewed fallback, not an availability claim. Monitoring has metrics only;\nFrontend/MetricsEndpoint have no configurable retention. Unknown support is not\npermission. Whole-day limits: logs/traces 1-30, metrics/downsampling 1-780.\nNever change plans or discard saved values silently. Optional overrides are not\nmandatory decisions. Editor, preparation and proposal acceptance share validation.\n\n## Limits\n\nEstimates need measured volume and current limits. Default ACLs are not\ninherently private. Demos prove no production coverage. Typed Editor proposals\nchange only the local draft; execution is separate.\n\n## Credentials\n\nSeparate provisioning from ingestion/dashboard access. No passwords, tokens,\nOTLP secrets or customer logs in chat.\n\n## Sources\n\nNative variables and manifest/engine binding; refresh after review.\nLive product facts and operational qualification remain separate.\n", sha256: - "73b9c3197c5a6f3c879d9869856ad520c81cccdc916b9a2a618827b0a3dbcc13", + "4f9782dcc14ae4a83857ff539c462347f45e81c026ffb7bd5e0388c177ed9649", }, { id: "audit-logs", @@ -418,17 +451,17 @@ export const assistantKnowledgeTopics = { { path: "src/variables.tf", sha256: - "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", }, { path: "src/main.tf", sha256: - "8592367381556c5eb6ae376d4bfe58ff4afe8bb7c37e1b8d8052d7678834d4d9", + "0b2f27c869b6afe2be5614fe8895966739f874f18d1fd8905224735703b249c6", }, { path: "src/modules/management/variables.tf", sha256: - "be76927b66e60781b9f83c4b08bf71089c7411761e22ac7064ab2506dd8a6b72", + "1a230ff4236020cce81fe390972f5bed083c0e9f66e401ba707caab0f68059d9", }, { path: "src/modules/management/6-audit-logs.tf", @@ -474,17 +507,17 @@ export const assistantKnowledgeTopics = { { path: "src/variables.tf", sha256: - "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", }, { path: "src/main.tf", sha256: - "8592367381556c5eb6ae376d4bfe58ff4afe8bb7c37e1b8d8052d7678834d4d9", + "0b2f27c869b6afe2be5614fe8895966739f874f18d1fd8905224735703b249c6", }, { path: "src/modules/management/variables.tf", sha256: - "be76927b66e60781b9f83c4b08bf71089c7411761e22ac7064ab2506dd8a6b72", + "1a230ff4236020cce81fe390972f5bed083c0e9f66e401ba707caab0f68059d9", }, { path: "src/modules/management/4-service-account.tf", @@ -550,12 +583,12 @@ export const assistantKnowledgeTopics = { { path: "src/main.tf", sha256: - "8592367381556c5eb6ae376d4bfe58ff4afe8bb7c37e1b8d8052d7678834d4d9", + "0b2f27c869b6afe2be5614fe8895966739f874f18d1fd8905224735703b249c6", }, { path: "landing-zone-configurator/app/packages/domain/src/common-validation.ts", sha256: - "12b1a51aef9055e0146f41fa30f9ba03de0e6e078f78597cc488da9884f0761f", + "27b7edb026926aef1b6efb4f8e2c131d43a37afb2abaa78f0d600006a5f4a492", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -641,7 +674,7 @@ export const assistantKnowledgeTopics = { { path: "src/main.tf", sha256: - "8592367381556c5eb6ae376d4bfe58ff4afe8bb7c37e1b8d8052d7678834d4d9", + "0b2f27c869b6afe2be5614fe8895966739f874f18d1fd8905224735703b249c6", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -726,12 +759,12 @@ export const assistantKnowledgeTopics = { { path: "src/variables.tf", sha256: - "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", }, { path: "src/main.tf", sha256: - "8592367381556c5eb6ae376d4bfe58ff4afe8bb7c37e1b8d8052d7678834d4d9", + "0b2f27c869b6afe2be5614fe8895966739f874f18d1fd8905224735703b249c6", }, { path: "src/modules/connectivity/variables.tf", @@ -903,7 +936,7 @@ export const assistantKnowledgeTopics = { { path: "src/variables.tf", sha256: - "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", }, { path: "src/modules/firewall-config/variables.tf", @@ -913,7 +946,7 @@ export const assistantKnowledgeTopics = { { path: "src/main.tf", sha256: - "8592367381556c5eb6ae376d4bfe58ff4afe8bb7c37e1b8d8052d7678834d4d9", + "0b2f27c869b6afe2be5614fe8895966739f874f18d1fd8905224735703b249c6", }, { path: "src/providers.tf", @@ -1027,7 +1060,7 @@ export const assistantKnowledgeTopics = { { path: "src/variables.tf", sha256: - "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", }, { path: "src/_firewall-bootstrap.tf", @@ -1042,7 +1075,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/docs/deployment-credentials.md", sha256: - "d517eaa07289b481bb9bca4bb02d489b47dd129a6f8bfee7c96f15e78e62112c", + "e003def890db3ea64fff99593d8dae7d2cb635541bb5eb2be5a71abea04322e2", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -1077,7 +1110,7 @@ export const assistantKnowledgeTopics = { { path: "src/variables.tf", sha256: - "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", }, { path: "src/modules/devops/variables.tf", @@ -1087,7 +1120,7 @@ export const assistantKnowledgeTopics = { { path: "src/main.tf", sha256: - "8592367381556c5eb6ae376d4bfe58ff4afe8bb7c37e1b8d8052d7678834d4d9", + "0b2f27c869b6afe2be5614fe8895966739f874f18d1fd8905224735703b249c6", }, { path: "src/providers.tf", @@ -1131,7 +1164,7 @@ export const assistantKnowledgeTopics = { { path: "src/variables.tf", sha256: - "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", }, { path: "src/modules/platform-kubernetes/variables.tf", @@ -1146,7 +1179,7 @@ export const assistantKnowledgeTopics = { { path: "src/main.tf", sha256: - "8592367381556c5eb6ae376d4bfe58ff4afe8bb7c37e1b8d8052d7678834d4d9", + "0b2f27c869b6afe2be5614fe8895966739f874f18d1fd8905224735703b249c6", }, { path: "src/providers.tf", @@ -1156,7 +1189,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/packages/domain/src/common-validation.ts", sha256: - "12b1a51aef9055e0146f41fa30f9ba03de0e6e078f78597cc488da9884f0761f", + "27b7edb026926aef1b6efb4f8e2c131d43a37afb2abaa78f0d600006a5f4a492", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -1258,17 +1291,17 @@ export const assistantKnowledgeTopics = { { path: "src/variables.tf", sha256: - "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", }, { path: "src/modules/landing-zone/variables.tf", sha256: - "e7efc116c2446b631d6faba73ae525627c8494af8d444902f5b775ed971b9096", + "c8ec47c9c00d4b7af5f84025bf5a2b443c99d7d6aa17952f24517697504c0905", }, { path: "src/main.tf", sha256: - "8592367381556c5eb6ae376d4bfe58ff4afe8bb7c37e1b8d8052d7678834d4d9", + "0b2f27c869b6afe2be5614fe8895966739f874f18d1fd8905224735703b249c6", }, { path: "landing-zone-configurator/app/packages/domain/src/common-document.ts", @@ -1283,7 +1316,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/packages/domain/src/common-validation.ts", sha256: - "12b1a51aef9055e0146f41fa30f9ba03de0e6e078f78597cc488da9884f0761f", + "27b7edb026926aef1b6efb4f8e2c131d43a37afb2abaa78f0d600006a5f4a492", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -1343,7 +1376,7 @@ export const assistantKnowledgeTopics = { { path: "src/variables.tf", sha256: - "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", }, { path: "src/modules/sandboxes/variables.tf", @@ -1353,7 +1386,7 @@ export const assistantKnowledgeTopics = { { path: "src/main.tf", sha256: - "8592367381556c5eb6ae376d4bfe58ff4afe8bb7c37e1b8d8052d7678834d4d9", + "0b2f27c869b6afe2be5614fe8895966739f874f18d1fd8905224735703b249c6", }, { path: "landing-zone-configurator/app/packages/domain/src/common-document.ts", @@ -1396,7 +1429,7 @@ export const assistantKnowledgeTopics = { { path: "src/variables.tf", sha256: - "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", }, { path: "src/_landing-zone-kubernetes.tf", @@ -1416,7 +1449,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/packages/domain/src/common-validation.ts", sha256: - "12b1a51aef9055e0146f41fa30f9ba03de0e6e078f78597cc488da9884f0761f", + "27b7edb026926aef1b6efb4f8e2c131d43a37afb2abaa78f0d600006a5f4a492", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -1492,7 +1525,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/apps/api/src/assistant/service.ts", sha256: - "366c4ba5c917f77ddfe35ae4dafbe1a414c42f9ceac01825b77120e0cdde67c9", + "6386fabea8196f11eae86f7ff4684f143f87940bd6addaec8fbd4d569a62b9a0", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/dialogue.ts", @@ -1512,7 +1545,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts", sha256: - "afd22388e2326e4cf45f6fdc970e7725448a8ac11d2be38333cd02ea19642f07", + "630dd873cfcff63dadc258c41ccc0a63a654059a5d9347f2ef86c66438aed327", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/knowledge.ts", @@ -1522,7 +1555,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/apps/api/src/assistant/topic-fields.ts", sha256: - "2a9b74c709be67e3dc737af0f30a0dd9df6a07b5e0a3bde46f4c14b4dc5bd6f3", + "56209a0eea70927042b8799f5e12b3005b53ed9c1584fc9b77cbc99986b174b2", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.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 dfb003e2..eda0c85d 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 @@ -1346,7 +1346,7 @@ export class AssistantModelServing { [ { role: "system", - content: `Translate the CURRENT user task into reviewable native Editor settings using ONLY the supplied closed schemas. Reply in the user's language. This Editor contract supersedes historical assistant claims that audit/VPN/platform/projects are unsupported. Firewall policy, secrets, identity binding, publication and execution are excluded. Never generate arbitrary JSON/HCL, cloud IDs, credentials or verified identities. Actual user messages/current saved values/available catalogue entries are the only sources for addresses, emails, IDs and product selections. Never fabricate availability, performance, quotas, compliance or event delivery. + content: `Translate the CURRENT user task into reviewable native Editor settings using ONLY the supplied closed schemas. Reply in the user's language. This Editor contract supersedes historical assistant claims that audit/VPN/platform/projects are unsupported. Firewall policy, secrets, identity binding, publication and execution are excluded. Never generate arbitrary JSON/HCL, cloud IDs, credentials or verified identities. Actual user messages/current saved values/available catalogue entries are the only sources for addresses, emails, IDs and product selections. Never fabricate availability, performance, quotas, compliance or event delivery. Respect supplied service-plan capability flags: false forbids an option, absent means unknown. Use the reviewed static capability/retention policy only as an explicitly identified fallback; never silently change a plan or discard incompatible saved values. Choose at most three clear business questions when essential information is missing, with up to six meaningful options. ACL questions use free text and no invented network examples or selectable CIDRs: ask the user for their actual access networks. A setup request without essential choices returns questions only and changes=[]; never propose an incomplete new component. Reuse actual user answers, not assistant suggestions; do not repeat old firewall questions after a topic switch. Explain concrete choices briefly, not a dump of every field. A reply containing only values continues the language of the latest natural-language USER turn; technical field names do not imply English. 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. diff --git a/landing-zone-configurator/app/apps/api/src/assistant/service.ts b/landing-zone-configurator/app/apps/api/src/assistant/service.ts index a27e08fc..8c6c8c80 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/service.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/service.ts @@ -15,7 +15,9 @@ import { compileCommonConfiguration, effectiveInput, isCommonDraft, + type ObservabilityPlanCatalogue, objectValue, + observabilityRetentionFindings, readEditorDraft, } from "@lzc/domain"; import type pg from "pg"; @@ -314,6 +316,76 @@ export class AssistantConversations { Partial>, ) {} + private async loadObservabilityCatalogue( + session: Session, + id: string, + localDocument?: unknown, + changes?: { path: string }[], + ): Promise { + const affectsObservability = (items: { path: string }[]) => + items.some( + (change) => + change.path === "observability" || + change.path.startsWith("observability."), + ); + if (!this.catalogues || (changes && !affectsObservability(changes))) + return undefined; + const region = await this.work( + session, + id, + async (client, configuration) => { + if (!changes) { + const row = ( + await client.query( + "SELECT proposal FROM lzc.assistant_conversations WHERE configuration_id=$1", + [id], + ) + ).rows[0]; + const proposal = z + .object({ changes: z.array(z.object({ path: z.string() })) }) + .safeParse(row?.proposal); + if (!proposal.success || !affectsObservability(proposal.data.changes)) + return undefined; + } + const draft = readEditorDraft(localDocument ?? configuration.document); + if (!isCommonDraft(draft)) return undefined; + return compileCommonConfiguration(draft).region === "eu02" + ? ("eu02" as const) + : ("eu01" as const); + }, + ); + if (!region) return undefined; + // Credential/network reads finish before opening the proposal write transaction. + return this.catalogues.automatic(session, region).catch(() => undefined); + } + + private checkObservabilitySelection( + draft: Parameters[0], + changes: { path: string }[], + catalogue?: ObservabilityPlanCatalogue, + ) { + if ( + !changes.some( + (change) => + change.path === "observability" || + change.path.startsWith("observability."), + ) + ) + return; + const values = compileCommonConfiguration(draft); + if (values.observability == null) return; + const region = values.region === "eu02" ? "eu02" : "eu01"; + const report = observabilityRetentionFindings( + objectValue(values.observability), + catalogue?.region === region ? catalogue : undefined, + ); + if (report.findings.length) + throw new AssistantError( + 422, + "assistant_observability_plan_incompatible", + ); + } + private async productCatalogues( session: Session, regions: ("eu01" | "eu02")[], @@ -378,7 +450,11 @@ export class AssistantConversations { part?.status === "available" ? part.options .slice(0, 50) - .map(({ value, label }) => ({ value, label })) + .map(({ value, label, capabilities }) => ({ + value, + label, + ...(capabilities ? { capabilities } : {}), + })) : [], totalOptions: part?.options.length ?? 0, truncated: (part?.options.length ?? 0) > 50, @@ -665,6 +741,13 @@ export class AssistantConversations { knowledge.rules.map((rule) => rule.id), ); stream?.signal?.throwIfAborted(); + const catalogue = await this.loadObservabilityCatalogue( + session, + id, + undefined, + proposal.changes, + ); + stream?.signal?.throwIfAborted(); await this.work(session, id, async (_client, configuration) => { if (configuration.revision !== revision) throw new AssistantError(409, "configuration_changed"); @@ -675,7 +758,13 @@ export class AssistantConversations { "assistant_common_configuration_required", ); try { - applyAssistantChanges(draft, proposal.changes); + const candidate = applyAssistantChanges(draft, proposal.changes); + if (isCommonDraft(candidate)) + this.checkObservabilitySelection( + candidate, + proposal.changes, + catalogue, + ); } catch (error) { if (error instanceof z.ZodError) throw new AssistantInferenceError("invalid_scope"); @@ -728,7 +817,7 @@ export class AssistantConversations { } } - preview( + async preview( session: Session, id: string, conversationRevision: number, @@ -738,6 +827,11 @@ export class AssistantConversations { baselineDocument: unknown, confirmedRevision?: number, ) { + const catalogue = await this.loadObservabilityCatalogue( + session, + id, + localDocument, + ); return this.work(session, id, async (client, configuration) => { const row = ( await client.query( @@ -845,6 +939,8 @@ export class AssistantConversations { let candidate: ReturnType; try { candidate = applyAssistantChanges(draft, selection); + if (isCommonDraft(candidate)) + this.checkObservabilitySelection(candidate, selection, catalogue); } catch (error) { if ( error instanceof z.ZodError || @@ -915,13 +1011,14 @@ export class AssistantConversations { }); } - accept( + async accept( session: Session, id: string, conversationRevision: number, baseRevision: number, selectedFields?: string[], ) { + const catalogue = await this.loadObservabilityCatalogue(session, id); return this.work(session, id, async (client, configuration) => { const row = ( await client.query( @@ -976,6 +1073,8 @@ export class AssistantConversations { let candidate: ReturnType; try { candidate = applyAssistantChanges(draft, changes); + if (isCommonDraft(candidate)) + this.checkObservabilitySelection(candidate, changes, catalogue); } catch (error) { if ( selectedFields !== undefined && diff --git a/landing-zone-configurator/app/apps/api/src/assistant/topic-fields.ts b/landing-zone-configurator/app/apps/api/src/assistant/topic-fields.ts index 2134f5c9..cdd7723a 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/topic-fields.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/topic-fields.ts @@ -44,7 +44,7 @@ export const assistantTopicFields: Record< "organization_auditors", ], }, - "management-state": { paths: [], prefixes: [] }, + "management-state": { paths: [], prefixes: ["bucket_naming"] }, observability: { paths: [], prefixes: ["observability"] }, "audit-logs": { paths: [], prefixes: ["audit_logs"] }, "federated-identities": { diff --git a/landing-zone-configurator/app/apps/api/src/credentials/catalogues.ts b/landing-zone-configurator/app/apps/api/src/credentials/catalogues.ts index 4c4413d6..4831f5cd 100644 --- a/landing-zone-configurator/app/apps/api/src/credentials/catalogues.ts +++ b/landing-zone-configurator/app/apps/api/src/credentials/catalogues.ts @@ -1,3 +1,4 @@ +import type { ObservabilityPlanCapabilities } from "@lzc/domain"; import type pg from "pg"; import { z } from "zod"; import type { Session } from "../auth/store.js"; @@ -15,7 +16,11 @@ export const catalogueRequest = z }) .strict(); export type CatalogueRequest = z.infer; -export type CatalogueOption = { value: string; label: string }; +export type CatalogueOption = { + value: string; + label: string; + capabilities?: ObservabilityPlanCapabilities; +}; export type CataloguePart = { status: "available" | "unavailable"; options: CatalogueOption[]; @@ -107,7 +112,16 @@ const ske = z.object({ volumeTypes: names, }); const observability = z.object({ - plans: z.array(z.object({ name: text.optional() })).max(2000), + plans: z + .array( + z.object({ + name: text.optional(), + totalMetricSamples: z.number().int().nonnegative().optional(), + logsStorage: z.number().int().nonnegative().optional(), + tracesStorage: z.number().int().nonnegative().optional(), + }), + ) + .max(2000), }); const iaasMachines = z.object({ items: names }); const iaasImages = z.object({ @@ -477,7 +491,31 @@ export class StackitCatalogueClient { if (observabilityResult.status === "fulfilled") result.observabilityPlans = available( observabilityResult.value.plans.flatMap((v) => - v.name ? [{ value: v.name, label: v.name }] : [], + v.name + ? [ + { + value: v.name, + label: v.name, + ...(v.totalMetricSamples !== undefined || + v.logsStorage !== undefined || + v.tracesStorage !== undefined + ? { + capabilities: { + ...(v.totalMetricSamples === undefined + ? {} + : { metricsRetention: v.totalMetricSamples > 0 }), + ...(v.logsStorage === undefined + ? {} + : { logsRetention: v.logsStorage > 0 }), + ...(v.tracesStorage === undefined + ? {} + : { tracesRetention: v.tracesStorage > 0 }), + }, + } + : {}), + }, + ] + : [], ), ); if (machinesResult.status === "fulfilled") diff --git a/landing-zone-configurator/app/apps/api/src/deployments/preparations.ts b/landing-zone-configurator/app/apps/api/src/deployments/preparations.ts index e7152d36..3f45e341 100644 --- a/landing-zone-configurator/app/apps/api/src/deployments/preparations.ts +++ b/landing-zone-configurator/app/apps/api/src/deployments/preparations.ts @@ -7,6 +7,8 @@ import { type ConfigurationRecord, initialPlanIssues, initialPlanRequiresFirewallAdministration, + objectValue, + observabilityRetentionFindings, recordName, recordOrganization, recordValues, @@ -18,6 +20,7 @@ import type pg from "pg"; import { z } from "zod"; import type { Session } from "../auth/store.js"; import { Configurations } from "../configurations/service.js"; +import type { PostgresCloudCatalogues } from "../credentials/catalogues.js"; import type { CredentialCheck } from "../credentials/check.js"; import { CredentialError, @@ -126,6 +129,11 @@ export function preparationManifest( ): PreparationManifest { if (initialPlanIssues(snapshot.document).length) throw new CredentialError(409, "configuration_execution_not_supported"); + if ( + recordValues(snapshot.document).bucket_naming !== undefined && + !input.platformUpgrade + ) + throw new CredentialError(409, "configuration_execution_not_supported"); const requiresAdministration = initialPlanRequiresFirewallAdministration( snapshot.document, ); @@ -215,6 +223,7 @@ export class Preparations { >, private readonly platformUpgradeEnabled = false, private readonly runnerAcceleratorCommit = acceleratorCommit, + private readonly catalogues?: Pick, ) {} capabilities() { return { @@ -289,6 +298,19 @@ export class Preparations { ); if (checked.check.status !== "passed") throw new CredentialError(422, checked.check.code); + const sourceValues = recordValues(snapshot.document); + if (sourceValues.observability != null) { + const region = sourceValues.region === "eu02" ? "eu02" : "eu01"; + const live = await this.catalogues + ?.automatic(session, region) + .catch(() => undefined); + const report = observabilityRetentionFindings( + objectValue(sourceValues.observability), + live?.region === region ? live : undefined, + ); + if (report.findings.length) + throw new CredentialError(422, "observability_plan_incompatible"); + } const manifest = preparationManifest( input, snapshot, @@ -327,14 +349,59 @@ export class Preparations { if (input.platformUpgrade && bound) { const current = ( await c.query( - "SELECT version,backend_id,lock_run_id FROM lzc.platform_states WHERE state_key=$1 FOR UPDATE", + "SELECT version,backend_id,lock_run_id,pending_backend_id,migration_run_id,ciphertext IS NOT NULL AS checkpoint_present FROM lzc.platform_states WHERE state_key=$1 FOR UPDATE", [bound.state_key], ) ).rows[0]; if (current?.lock_run_id) throw new CredentialError(423, "state_locked"); - if (!current || (Number(current.version) > 0 && !current.backend_id)) + if (!current) throw new CredentialError(409, "platform_upgrade_state_unavailable"); + if (Number(current.version) > 0 && !current.backend_id) { + // A CAS state version identifies the checkpoint already verified by + // explicit reconciliation. Never make a fresh/empty bootstrap here. + const compatibleSources = [ + platformUpgradeAcceleratorCommit, + "c4b43c36af198985980b17626c48d357795e3fbd", + ]; + const reviewed = + !current.checkpoint_present || + current.pending_backend_id || + current.migration_run_id + ? false + : ( + await c.query( + `SELECT EXISTS( + SELECT 1 FROM lzc.platform_reconciliations receipt + JOIN lzc.plan_runs run ON run.id=receipt.run_id + JOIN lzc.deployment_preparations original ON original.id=run.preparation_id + WHERE receipt.state_key=$1 AND receipt.state_version=$2 + AND run.state_key=receipt.state_key AND run.operation='apply' + AND run.status='failed' AND run.error_code='apply_failed' + AND run.input_claimed=true AND run.finished_at IS NOT NULL + AND original.manifest#>>'{source,configurationId}'=$3 + AND original.manifest#>>'{platformContractNamespace}'=$4 + AND original.manifest#>>'{accelerator,commit}'=ANY($5::text[]) + AND NOT EXISTS(SELECT 1 FROM lzc.state_recoveries recovery + JOIN lzc.plan_runs other ON other.id=recovery.run_id WHERE other.state_key=$1) + AND NOT EXISTS(SELECT 1 FROM lzc.plan_runs active WHERE active.state_key=$1 + AND active.status IN ('starting','initializing','validating','planning','applying','recovery_required')) + ) AS allowed`, + [ + bound.state_key, + current.version, + input.configurationId, + session.tenantId, + compatibleSources, + ], + ) + ).rows[0]?.allowed === true; + if (!reviewed) + throw new CredentialError( + 409, + "platform_upgrade_state_unavailable", + ); + } } if (input.backendId && bound && input.backendId !== bound.backend_id) throw new CredentialError(409, "backend_binding_changed"); diff --git a/landing-zone-configurator/app/apps/api/src/main.ts b/landing-zone-configurator/app/apps/api/src/main.ts index 316afa26..018723f1 100644 --- a/landing-zone-configurator/app/apps/api/src/main.ts +++ b/landing-zone-configurator/app/apps/api/src/main.ts @@ -286,6 +286,7 @@ const app = buildApp({ credentials, executionEnabled, plans?.acceleratorCommit, + catalogues, ), } : {}), diff --git a/landing-zone-configurator/app/apps/web/src/components/AssistantChat.tsx b/landing-zone-configurator/app/apps/web/src/components/AssistantChat.tsx index 3954ea92..98bb74dc 100644 --- a/landing-zone-configurator/app/apps/web/src/components/AssistantChat.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/AssistantChat.tsx @@ -68,6 +68,8 @@ class MergeConfirmation extends Error { const errors: Record = { assistant_inference_failed: "Die Modellantwort konnte nicht sicher verarbeitet werden. Bitte erneut versuchen; deine Anfrage bleibt erhalten und es wurde nichts geändert.", + assistant_observability_plan_incompatible: + "Der Observability-Vorschlag passt nicht zu den geprüften Tariffähigkeiten. Tarif und Aufbewahrung prüfen; vorhandene Werte bleiben erhalten.", assistant_invalid_request: "Bitte keine Zugangsdaten senden. Nachricht und Vorschlag müssen innerhalb der erlaubten Grenzen bleiben.", assistant_invalid_selection: diff --git a/landing-zone-configurator/app/apps/web/src/components/CloudCatalogues.tsx b/landing-zone-configurator/app/apps/web/src/components/CloudCatalogues.tsx index 9f283598..f1742384 100644 --- a/landing-zone-configurator/app/apps/web/src/components/CloudCatalogues.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/CloudCatalogues.tsx @@ -80,6 +80,11 @@ export function useCatalogueOptions(path: string, region: string) { const part = data[field]; return part?.status === "available" ? part.options : undefined; } +export function useObservabilityCatalogue(region: string) { + const context = useContext(CatalogueContext); + const data = context.regions[region] ?? context.data; + return data?.region === region ? data : undefined; +} export function useCatalogueRoleTemplates() { return useContext(CatalogueContext).data?.projectRoleTemplates ?? []; } diff --git a/landing-zone-configurator/app/apps/web/src/components/CommonEditor.tsx b/landing-zone-configurator/app/apps/web/src/components/CommonEditor.tsx index 8916a482..e16aa94b 100644 --- a/landing-zone-configurator/app/apps/web/src/components/CommonEditor.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/CommonEditor.tsx @@ -22,6 +22,7 @@ import { import { useState } from "react"; import { t } from "../i18n"; import type { EditorStep } from "../navigation"; +import { useObservabilityCatalogue } from "./CloudCatalogues"; import { ComponentPicker } from "./ComponentPicker"; import { FederatedIdentityEditor } from "./FederatedIdentityEditor"; import { labelFor } from "./feature-labels"; @@ -125,8 +126,32 @@ export function CommonEditor({ const projects = commonProjects(draft); const areas = commonNetworkAreas(draft); const folders = folderNames(values); - const issues = editorIssues(draft); - const assessment = assessCommonConfiguration(draft); + const liveCatalogue = useObservabilityCatalogue( + String(values.region ?? "eu01"), + ); + const assessment = assessCommonConfiguration(draft, liveCatalogue); + const replacedMessages = new Set( + assessCommonConfiguration(draft) + .findings.filter( + (finding) => + finding.path.startsWith("observability.") && + finding.code.includes("observability"), + ) + .map((finding) => JSON.stringify([finding.path, finding.message])), + ); + const issues = [ + ...editorIssues(draft).filter( + (issue) => + !replacedMessages.has(JSON.stringify([issue.field, issue.message])), + ), + ...assessment.findings + .filter( + (finding) => + finding.path.startsWith("observability.") && + finding.severity === "error", + ) + .map((finding) => ({ field: finding.path, message: finding.message })), + ]; const active = steps.find((item) => item.id === step) ?? steps[0]; const perform = (action: () => CommonConfiguration) => { try { diff --git a/landing-zone-configurator/app/apps/web/src/components/Credentials.tsx b/landing-zone-configurator/app/apps/web/src/components/Credentials.tsx index 9d8b3b21..e67b8fe8 100644 --- a/landing-zone-configurator/app/apps/web/src/components/Credentials.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/Credentials.tsx @@ -243,7 +243,7 @@ export function Credentials({ setFirewallPassword(event.target.value) diff --git a/landing-zone-configurator/app/apps/web/src/components/Deployments.tsx b/landing-zone-configurator/app/apps/web/src/components/Deployments.tsx index 61fc1ab5..f8299da2 100644 --- a/landing-zone-configurator/app/apps/web/src/components/Deployments.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/Deployments.tsx @@ -47,6 +47,8 @@ type Preparation = { }; }; const errors: Record = { + observability_plan_incompatible: + "Der gewählte Observability-Tarif und seine Einstellungen passen nicht zusammen oder konnten nicht verifiziert werden. Prüfe Tarif und Aufbewahrung im Editor. Gespeicherte Werte bleiben erhalten.", firewall_administration_required: "Für Firewall-HA fehlt ein gebundener OPNsense-Administrationszugang. Erstelle unter Zugänge ein Profil mit Service-Account und OPNsense-Zugang und danach eine neue Vorbereitung und einen neuen Plan. Bestehende Nachweise bleiben erhalten.", configuration_execution_not_supported: @@ -77,7 +79,7 @@ const errors: Record = { platform_upgrade_unavailable: "Der aktuelle Accelerator-Stand ist für neue Vorbereitungen nicht freigegeben. Bitte die Runner-Konfiguration prüfen lassen.", platform_upgrade_state_unavailable: - "Für den Accelerator-Wechsel einer bereits bereitgestellten Plattform muss deren vorhandener State an ein S3-Backend gebunden sein. Prüfe die State-Übernahme; deine Konfiguration bleibt unverändert.", + "Für den Accelerator-Wechsel ist der vorhandene S3-State oder ein ausdrücklich geprüfter, unterstützter Bootstrap-Checkpoint erforderlich. Prüfe den Checkpoint und offene Ausführungen; deine Konfiguration bleibt erhalten.", state_locked: "Der Plattform-State ist durch eine laufende Ausführung gesperrt. Prüfe den Ausführungsstatus und warte auf deren Abschluss.", backend_not_found: diff --git a/landing-zone-configurator/app/apps/web/src/components/StructuredField.tsx b/landing-zone-configurator/app/apps/web/src/components/StructuredField.tsx index 5ee106a1..c07140f4 100644 --- a/landing-zone-configurator/app/apps/web/src/components/StructuredField.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/StructuredField.tsx @@ -1,7 +1,9 @@ import { type InputType, type JsonValue, + type ObservabilityPlanCapabilities, objectValue, + observabilityPlanCapabilities, regionalConnectivityType, } from "@lzc/domain"; import { useId, useState } from "react"; @@ -9,6 +11,7 @@ import { t } from "../i18n"; import { useCatalogueOptions, useCatalogueRoleTemplates, + useObservabilityCatalogue, } from "./CloudCatalogues"; import { fieldDescription, fieldLabel } from "./feature-labels"; @@ -43,6 +46,7 @@ export function StructuredField({ regionContext = "eu01", roleNames = [], hideInactiveDetails = false, + retentionSupport, }: { name: string; type: InputType; @@ -60,6 +64,7 @@ export function StructuredField({ regionContext?: string; roleNames?: string[]; hideInactiveDetails?: boolean; + retentionSupport?: ObservabilityPlanCapabilities | undefined; }) { const id = useId(); const [newKey, setNewKey] = useState(""); @@ -69,7 +74,9 @@ export function StructuredField({ const missing = value === undefined || value === null; const standard = effective === undefined || effective === null - ? "nicht aktiviert" + ? /observability\.(?:logs|traces|metrics)_retention_days/.test(path) + ? "Servicestandard des Tarifs" + : "nicht aktiviert" : typeof effective === "boolean" ? effective ? "eingeschaltet" @@ -79,6 +86,26 @@ export function StructuredField({ ? "keine Einträge" : "Voreinstellung des Accelerators" : String(effective); + const liveObservability = useObservabilityCatalogue(regionContext); + const support = + path === "observability" || path.endsWith(".observability") + ? observabilityPlanCapabilities( + String( + objectValue(value).plan_name ?? + objectValue(effective).plan_name ?? + "Observability-Starter-EU01", + ), + liveObservability, + ) + : retentionSupport; + const unsupportedRetention = + name === "logs_retention_days" + ? support?.logsRetention === false + : name === "traces_retention_days" + ? support?.tracesRetention === false + : name.startsWith("metrics_retention_days") + ? support?.metricsRetention === false + : false; const cloudOptions = useCatalogueOptions(path, regionContext); const roleTemplates = useCatalogueRoleTemplates(); const customRoles = objectValue(value).custom_roles; @@ -101,46 +128,50 @@ export function StructuredField({ : []), ]), ] - : path.endsWith("role_assignments[*].role") && projectRoleNames.length - ? projectRoleNames - : path.endsWith(".dns_zones[*].type") - ? ["primary", "secondary"] - : path.includes(".vpn.") && path.endsWith(".encryption_algorithms[*]") - ? ["aes256", "aes128gcm16", "aes256gcm16"] - : path.includes(".vpn.") && path.endsWith(".integrity_algorithms[*]") - ? ["sha1", "sha2_256", "sha2_384", "sha2_512"] - : path.includes(".vpn.") && path.endsWith(".dh_groups[*]") - ? ["modp1024", "modp2048", "ecp256", "ecp384", "modp2048s256"] - : path.endsWith(".network_area_key") - ? (networkAreaKeys[regionContext] ?? []) - : path === "firewall_config.aliases[*].type" - ? [ - "host", - "network", - "port", - "url", - "urltable", - "urljson", - "geoip", - "asn", - "networkgroup", - "mac", - "external", - ] - : path === "firewall_config.rules[*].action" - ? ["pass", "block", "reject"] - : path === "firewall_config.rules[*].direction" - ? ["in", "out"] - : path === "region" || path.endsWith(".region") - ? ["eu01", "eu02"] - : path.endsWith(".secrets_enforcement.mode") - ? ["audit", "soft", "strict"] - : name === "resource_type" && - path.startsWith("audit_logs.") - ? ["organization", "folder", "project"] - : name === "routing_type" && path.includes(".vpn.") - ? ["POLICY_BASED", "ROUTE_BASED"] - : undefined; + : path === "bucket_naming" + ? ["project-id", "legacy"] + : path.endsWith("role_assignments[*].role") && projectRoleNames.length + ? projectRoleNames + : path.endsWith(".dns_zones[*].type") + ? ["primary", "secondary"] + : path.includes(".vpn.") && path.endsWith(".encryption_algorithms[*]") + ? ["aes256", "aes128gcm16", "aes256gcm16"] + : path.includes(".vpn.") && + path.endsWith(".integrity_algorithms[*]") + ? ["sha1", "sha2_256", "sha2_384", "sha2_512"] + : path.includes(".vpn.") && path.endsWith(".dh_groups[*]") + ? ["modp1024", "modp2048", "ecp256", "ecp384", "modp2048s256"] + : path.endsWith(".network_area_key") + ? (networkAreaKeys[regionContext] ?? []) + : path === "firewall_config.aliases[*].type" + ? [ + "host", + "network", + "port", + "url", + "urltable", + "urljson", + "geoip", + "asn", + "networkgroup", + "mac", + "external", + ] + : path === "firewall_config.rules[*].action" + ? ["pass", "block", "reject"] + : path === "firewall_config.rules[*].direction" + ? ["in", "out"] + : path === "region" || path.endsWith(".region") + ? ["eu01", "eu02"] + : path.endsWith(".secrets_enforcement.mode") + ? ["audit", "soft", "strict"] + : name === "resource_type" && + path.startsWith("audit_logs.") + ? ["organization", "folder", "project"] + : name === "routing_type" && + path.includes(".vpn.") + ? ["POLICY_BASED", "ROUTE_BASED"] + : undefined; const keyChoices = path === "connectivity_regions" ? ["eu01", "eu02"] @@ -205,6 +236,7 @@ export function StructuredField({ + {unsupportedRetention && ( +

+ {t( + "Vom gewählten Tarif nicht unterstützt. Vorhandene Werte werden beibehalten.", + )} +

+ )} {controls} ); @@ -262,6 +301,7 @@ export function StructuredField({ {t(fieldDescription(path, name))}

)} + {unsupportedRetention && ( +

+ {t( + "Vom gewählten Tarif nicht unterstützt. Vorhandene Werte werden beibehalten.", + )} +

+ )} {controls} ); @@ -370,6 +417,7 @@ export function StructuredField({ key={key} name={key} hideInactiveDetails={hideInactiveDetails} + retentionSupport={support} path={`${path}.${key}`} referenceKeys={referenceKeys} roleNames={projectRoleNames} @@ -394,6 +442,7 @@ export function StructuredField({ = { company_code: "Unternehmenskürzel", organization_id: "STACKIT Organisations-ID", region: "Region", + bucket_naming: "Bucket-Namensschema", platform_contract_namespace: "Namespace des Plattformvertrags", labels: "Kennzeichnungen", rm_folder_parent_id: "Vorhandener übergeordneter Ordner", 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 13bf85b9..e854b372 100644 --- a/landing-zone-configurator/app/apps/web/src/locales/en.ts +++ b/landing-zone-configurator/app/apps/web/src/locales/en.ts @@ -1,4 +1,27 @@ export const englishMessages: Record = { + "Der gewählte Observability-Tarif und seine Einstellungen passen nicht zusammen oder konnten nicht verifiziert werden. Prüfe Tarif und Aufbewahrung im Editor. Gespeicherte Werte bleiben erhalten.": + "The selected Observability plan and its settings are incompatible or could not be verified. Check the plan and retention in the editor. Saved values are preserved.", + "Der Observability-Vorschlag passt nicht zu den geprüften Tariffähigkeiten. Tarif und Aufbewahrung prüfen; vorhandene Werte bleiben erhalten.": + "The Observability proposal is incompatible with the verified plan capabilities. Check the plan and retention; existing values are preserved.", + "Bucket-Namensschema": "Bucket naming scheme", + "Servicestandard des Tarifs": "Service plan default", + "Vom gewählten Tarif nicht unterstützt. Vorhandene Werte werden beibehalten.": + "Not supported by the selected plan. Existing values are preserved.", + "Das Bucket-Namensschema muss project-id oder legacy sein.": + "Bucket naming must be project-id or legacy.", + "Der gewählte Observability-Tarif fehlt im aktuellen Katalog. Tarif prüfen; vorhandene Werte werden beibehalten.": + "The selected Observability plan is missing from the current catalogue. Check the plan; existing values are preserved.", + "Der gewählte Observability-Tarif unterstützt diese Aufbewahrungseinstellung nicht. Wert entfernen oder auf null setzen; Tarif nicht automatisch ändern.": + "The selected Observability plan does not support this retention setting. Remove it or set it to null; do not change the plan automatically.", + "Die Unterstützung dieser Aufbewahrungseinstellung ist weder live noch durch den geprüften Fallback bekannt. Vor Ausführung prüfen.": + "Support for this retention setting is unknown from both live data and the reviewed fallback. Verify before execution.", + "Tariffähigkeiten werden ganz oder teilweise anhand dokumentierter statischer Regeln geprüft; dies bestätigt keine aktuelle Verfügbarkeit im Zielprojekt.": + "Plan capabilities are checked partly or entirely using documented static rules; this does not confirm current availability in the target project.", + "Die Aufbewahrung muss eine ganze Zahl zwischen 1 und 30 Tagen sein (dokumentierter Servicegrenzwert).": + "Retention must be a whole number between 1 and 30 days (documented service limit).", + "Die Aufbewahrung muss eine ganze Zahl zwischen 1 und 780 Tagen sein (dokumentierter Servicegrenzwert).": + "Retention must be a whole number between 1 and 780 days (documented service limit).", + "OPNsense-Administrationszugang für Firewall-HA hinterlegen": "Store OPNsense administration credentials for firewall HA", "OPNsense-Benutzername": "OPNsense username", @@ -1084,8 +1107,8 @@ export const englishMessages: Record = { "Could not confirm the action. Refresh the list before retrying.", "Die Aktion konnte nicht bestätigt werden. Aktualisiere die Liste. Unvollständige Profile kannst du löschen und neu anlegen.": "Could not confirm the action. Refresh the list. Incomplete profiles can be deleted and recreated.", - "Für den Accelerator-Wechsel einer bereits bereitgestellten Plattform muss deren vorhandener State an ein S3-Backend gebunden sein. Prüfe die State-Übernahme; deine Konfiguration bleibt unverändert.": - "Changing the accelerator version of an already deployed platform requires its existing state to be bound to an S3 backend. Check the state migration; your configuration remains unchanged.", + "Für den Accelerator-Wechsel ist der vorhandene S3-State oder ein ausdrücklich geprüfter, unterstützter Bootstrap-Checkpoint erforderlich. Prüfe den Checkpoint und offene Ausführungen; deine Konfiguration bleibt erhalten.": + "Changing the accelerator version requires the existing S3 state or an explicitly reviewed, supported Bootstrap checkpoint. Check the checkpoint and pending executions; your configuration is preserved.", "Der Plattform-State ist durch eine laufende Ausführung gesperrt. Prüfe den Ausführungsstatus und warte auf deren Abschluss.": "The platform state is locked by a running execution. Check its status and wait for it to finish.", "Das ausgewählte State-Backend ist nicht mehr verfügbar. Wähle das zur Landing Zone gehörende Backend erneut aus.": diff --git a/landing-zone-configurator/app/e2e/cloud-catalogues.spec.ts b/landing-zone-configurator/app/e2e/cloud-catalogues.spec.ts index ae73a8b5..0f09572c 100644 --- a/landing-zone-configurator/app/e2e/cloud-catalogues.spec.ts +++ b/landing-zone-configurator/app/e2e/cloud-catalogues.spec.ts @@ -67,7 +67,15 @@ for (const kind of ["personal", "organisation"]) { "Observability-Monitoring-Medium-EU01", "Observability-Metrics-Endpoint-100k-EU01", "Observability-Frontend-Starter-EU01", - ].map((value) => ({ value, label: value })), + ].map((value) => ({ + value, + label: value, + capabilities: { + metricsRetention: value.includes("Monitoring"), + logsRetention: false, + tracesRetention: false, + }, + })), }, }, }); @@ -124,6 +132,77 @@ for (const kind of ["personal", "organisation"]) { () => document.documentElement.scrollWidth <= innerWidth, ), ).toBe(true); + await page + .getByRole("button", { name: "Komponente hinzufügen", exact: true }) + .click(); + await page + .getByRole("button", { + name: "Hinzufügen: STACKIT Observability", + exact: true, + }) + .click(); + await page + .getByRole("button", { + name: "Konfigurieren: Zentrale STACKIT Observability", + exact: true, + }) + .click(); + await page + .locator("summary") + .filter({ hasText: /^Zentrale STACKIT Observability/ }) + .click(); + await page + .getByRole("button", { + name: "Eigene Einstellung: Dienst-Leistungsklasse", + exact: true, + }) + .click(); + await page + .getByRole("button", { + name: "Eigene Einstellung: Protokollaufbewahrung (Tage)", + exact: true, + }) + .click(); + const logsRetention = page.getByLabel("Protokollaufbewahrung (Tage)", { + exact: true, + }); + await logsRetention.fill("7"); + await page + .getByLabel("Dienst-Leistungsklasse", { exact: true }) + .selectOption("Observability-Monitoring-Medium-EU01"); + await expect(logsRetention).toHaveValue("7"); + await expect(logsRetention).toBeDisabled(); + await logsRetention + .locator("..") + .getByRole("button", { + name: "Accelerator-Standard verwenden", + exact: true, + }) + .click(); + await expect( + page.getByRole("button", { + name: "Eigene Einstellung: Protokollaufbewahrung (Tage)", + exact: true, + }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { + name: "Eigene Einstellung: Metrikaufbewahrung (Tage)", + exact: true, + }), + ).toBeEnabled(); + await page + .getByLabel("Dienst-Leistungsklasse", { exact: true }) + .selectOption("Observability-Frontend-Starter-EU01"); + await expect( + page.getByRole("button", { + name: "Eigene Einstellung: Metrikaufbewahrung (Tage)", + exact: true, + }), + ).toBeDisabled(); + await page + .getByLabel("Dienst-Leistungsklasse", { exact: true }) + .selectOption("Observability-Monitoring-Medium-EU01"); await page .getByRole("button", { name: "5 Template-Entwürfe", diff --git a/landing-zone-configurator/app/e2e/configurator.spec.ts b/landing-zone-configurator/app/e2e/configurator.spec.ts index 38034844..f0b07190 100644 --- a/landing-zone-configurator/app/e2e/configurator.spec.ts +++ b/landing-zone-configurator/app/e2e/configurator.spec.ts @@ -4684,7 +4684,7 @@ test.describe("explicit saved-plan approval", () => { for (const [code, message] of [ [ "platform_upgrade_state_unavailable", - "vorhandener State an ein S3-Backend gebunden", + "vorhandene S3-State oder ein ausdrücklich geprüfter, unterstützter Bootstrap-Checkpoint", ], ["state_locked", "durch eine laufende Ausführung gesperrt"], [ diff --git a/landing-zone-configurator/app/integration/identity.test.ts b/landing-zone-configurator/app/integration/identity.test.ts index 70ac98d5..38966ba3 100644 --- a/landing-zone-configurator/app/integration/identity.test.ts +++ b/landing-zone-configurator/app/integration/identity.test.ts @@ -702,6 +702,103 @@ describe("real PostgreSQL session and tenant boundaries", () => { await configurations.remove(alice, stored.id, 1); }); + it("rechecks live retention capabilities before presentation, preview and acceptance without nested pool transactions", async () => { + const configurations = new Configurations(pool); + const original = createEditorConfiguration("standalone", randomUUID()); + const stored = await configurations.create(alice, original); + let supported = false; + const automatic = vi.fn( + async ( + session: Session, + region: "eu01" | "eu02", + ): Promise => { + // The pool has a single connection: network/credential reads must be outside + // the proposal write transaction or this read would deadlock. + await withTenant(pool, session, (client) => client.query("SELECT 1")); + const empty = { status: "unavailable" as const, options: [] }; + return { + region, + projectId: null, + fetchedAt: new Date().toISOString(), + gitFlavors: empty, + vpnPlans: empty, + kubernetesVersions: empty, + machineTypes: empty, + availabilityZones: empty, + volumeTypes: empty, + observabilityPlans: { + status: "available", + options: [ + { + value: "Observability-Starter-EU01", + label: "Starter", + capabilities: { + metricsRetention: true, + logsRetention: supported, + tracesRetention: true, + }, + }, + ], + }, + }; + }, + ); + const proposal: Dialogue = { + explanation: "Aufbewahrung für den gewählten Tarif prüfen.", + questions: [], + changes: [ + { + path: "observability", + operation: "merge", + value: { + plan_name: "Observability-Starter-EU01", + logs_retention_days: 7, + }, + }, + ], + references: ["observability"], + }; + const assistant = new AssistantConversations( + pool, + { + converse: vi.fn( + async () => proposal, + ), + }, + { automatic }, + ); + await expect( + assistant.send(alice, stored.id, 1, "Logs sieben Tage speichern"), + ).rejects.toThrow("assistant_observability_plan_incompatible"); + supported = true; + const conversation = await assistant.send( + alice, + stored.id, + 1, + "Logs sieben Tage speichern", + ); + supported = false; + await expect( + assistant.preview( + alice, + stored.id, + conversation.revision, + 1, + assistantProposalFields(conversation.proposal?.changes ?? []).map( + (field) => field.id, + ), + original, + original, + ), + ).rejects.toThrow("assistant_observability_plan_incompatible"); + await expect( + assistant.accept(alice, stored.id, conversation.revision, 1), + ).rejects.toThrow("assistant_observability_plan_incompatible"); + expect((await configurations.get(alice, stored.id)).revision).toBe(1); + expect(await assistant.get(alice, stored.id)).toEqual(conversation); + await configurations.remove(alice, stored.id, 1); + }); + it("validates SNA proposals before presentation and saves native configuration only after acceptance", async () => { const configurations = new Configurations(pool); const stored = await configurations.create( diff --git a/landing-zone-configurator/app/packages/contracts/src/plan.ts b/landing-zone-configurator/app/packages/contracts/src/plan.ts index fde7af7e..f2192d83 100644 --- a/landing-zone-configurator/app/packages/contracts/src/plan.ts +++ b/landing-zone-configurator/app/packages/contracts/src/plan.ts @@ -11,7 +11,7 @@ export const applicationRunnerSourceSchema = z.strictObject({ maintenanceEnabled: z.literal(true).optional(), }); export const platformUpgradeAcceleratorCommit = - "c4b43c36af198985980b17626c48d357795e3fbd"; + "9e7fb46c329aca32e878ce1f0ea3b944ea240354"; export const platformFirewallImage = { url: "https://opnsense.object.storage.eu01.onstackit.cloud/opnsense-26.1-amd64-21-05-2026.qcow2", sha256: "5d0fb1cb4375eb258859cafc688f1e5e6f830f54f8d86b9e8bb815611bae1068", diff --git a/landing-zone-configurator/app/packages/domain/src/accelerator-inputs.json b/landing-zone-configurator/app/packages/domain/src/accelerator-inputs.json index 5118be0a..48556697 100644 --- a/landing-zone-configurator/app/packages/domain/src/accelerator-inputs.json +++ b/landing-zone-configurator/app/packages/domain/src/accelerator-inputs.json @@ -1,10 +1,22 @@ { "schemaVersion": 1, - "sourceSha256": "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "sourceSha256": "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", "variables": [ + { + "name": "bucket_naming", + "line": 1, + "description": "Object Storage bucket naming scheme: legacy preserves existing names; project-id includes the complete owning project UUID for STACKIT-wide uniqueness. Changing this for existing buckets requires a reviewed migration.", + "type": "string", + "required": false, + "sensitive": false, + "default": "project-id", + "validations": [ + "validation {\n condition = contains([\"legacy\", \"project-id\"], var.bucket_naming)\n error_message = \"Bucket naming must be legacy or project-id.\"\n }" + ] + }, { "name": "owner_email", - "line": 5, + "line": 16, "description": "Email address of the owner. Required for STACKIT resource manager.", "type": "string", "required": true, @@ -12,7 +24,7 @@ }, { "name": "company_name", - "line": 10, + "line": 21, "description": "Name of the company.", "type": "string", "required": true, @@ -20,7 +32,7 @@ }, { "name": "company_code", - "line": 15, + "line": 26, "description": "Company code used in resource naming conventions.", "type": "string", "required": true, @@ -28,7 +40,7 @@ }, { "name": "organization_id", - "line": 20, + "line": 31, "description": "Container ID of the root organization.", "type": "string", "required": true, @@ -36,7 +48,7 @@ }, { "name": "rm_folder_parent_id", - "line": 25, + "line": 36, "description": "ID of the parent folder under which the resource manager folders will be created. If not provided, the resource manager folders will be created under the organization.", "type": "string", "required": false, @@ -45,7 +57,7 @@ }, { "name": "region", - "line": 31, + "line": 42, "description": "STACKIT region for regional resources.", "type": "string", "required": false, @@ -54,7 +66,7 @@ }, { "name": "platform_kubernetes_kube_config_override", - "line": 37, + "line": 48, "description": "Optional raw kubeconfig used by kubernetes/helm platform providers when no platform_kubernetes module output is available.", "type": "string", "required": false, @@ -62,7 +74,7 @@ }, { "name": "platform_contract_namespace", - "line": 44, + "line": 55, "description": "Optional stable UUID namespace for Application handoff; defaults to the organization ID for independent CLI use. This is metadata, not an authorization boundary.", "type": "string", "required": false, @@ -74,7 +86,7 @@ }, { "name": "labels", - "line": 55, + "line": 66, "description": "Additional labels to apply to all resources.", "type": [ "map", @@ -86,7 +98,7 @@ }, { "name": "organization_owners", - "line": 61, + "line": 72, "description": "List of organization owners.", "type": [ "list", @@ -98,7 +110,7 @@ }, { "name": "organization_auditors", - "line": 67, + "line": 78, "description": "List of organization auditors.", "type": [ "list", @@ -110,7 +122,7 @@ }, { "name": "devops", - "line": 73, + "line": 84, "description": "DevOps module configuration. Set to null to skip deployment.", "type": [ "object", @@ -140,7 +152,7 @@ }, { "name": "platform_kubernetes", - "line": 82, + "line": 93, "description": "Map of central, region-scoped platform Kubernetes deployments. Empty map skips deployment.", "type": [ "map", @@ -504,8 +516,8 @@ }, { "name": "observability", - "line": 184, - "description": "Observability instance configuration for the management module. Set to null to skip observability deployment.", + "line": 195, + "description": "Observability instance configuration for the management module. Omitted or null retention uses the service plan defaults. Monitoring plans require both retention fields to be omitted or null. Set to null to skip observability deployment.", "type": [ "object", { @@ -536,18 +548,19 @@ "nestedDefaults": { "values": { "acl": [], - "logs_retention_days": 30, - "metrics_retention_days": 90, - "metrics_retention_days_1h_downsampling": 90, - "metrics_retention_days_5m_downsampling": 90, - "plan_name": "Observability-Starter-EU01", - "traces_retention_days": 30 + "plan_name": "Observability-Starter-EU01" } - } + }, + "validations": [ + "validation {\n condition = var.observability == null ? true : (\n !startswith(var.observability.plan_name, \"Observability-Monitoring-\") ||\n (var.observability.logs_retention_days == null \u0026\u0026 var.observability.traces_retention_days == null)\n )\n error_message = \"Monitoring plans do not support logs or traces retention. Omit logs_retention_days and traces_retention_days or set them to null.\"\n }", + "validation {\n condition = var.observability == null ? true : (\n !can(regex(\"^Observability-(Frontend|Metrics-?Endpoint)\", var.observability.plan_name)) ||\n alltrue([for value in [var.observability.logs_retention_days, var.observability.traces_retention_days, var.observability.metrics_retention_days, var.observability.metrics_retention_days_5m_downsampling, var.observability.metrics_retention_days_1h_downsampling] : value == null])\n )\n error_message = \"Frontend and MetricsEndpoint plans do not support retention overrides. Omit all retention fields or set them to null.\"\n }", + "validation {\n condition = var.observability == null ? true : alltrue([\n for value in [var.observability.logs_retention_days, var.observability.traces_retention_days] : value == null ? true : value \u003e= 1 \u0026\u0026 value \u003c= 30 \u0026\u0026 floor(value) == value\n ])\n error_message = \"Logs and traces retention must be omitted, null or a whole number from 1 to 30 days.\"\n }", + "validation {\n condition = var.observability == null ? true : alltrue([\n for value in [var.observability.metrics_retention_days, var.observability.metrics_retention_days_5m_downsampling, var.observability.metrics_retention_days_1h_downsampling] : value == null ? true : value \u003e= 1 \u0026\u0026 value \u003c= 780 \u0026\u0026 floor(value) == value\n ])\n error_message = \"Metrics retention must be omitted, null or a whole number from 1 to 780 days.\"\n }" + ] }, { "name": "audit_logs", - "line": 198, + "line": 239, "description": "Audit log routing for the management module. A Telemetry Link streams audit events into a Telemetry Router, which forwards them to a Logs instance and archives them to object storage. link_scopes defaults to a single organization-wide link. retention_days applies to both the Logs instance and, when s3_object_lock is enabled, the archive bucket's default retention. s3_object_lock is off by default and turns on GOVERNANCE-mode WORM protection for the archive bucket, which is not cleanly reversible. Set to null to skip audit logs deployment.", "type": [ "object", @@ -592,7 +605,7 @@ }, { "name": "federated_identity_providers", - "line": 212, + "line": 253, "description": "List of federated identity providers to configure for the management service account.", "type": [ "list", @@ -621,7 +634,7 @@ }, { "name": "rm_folders", - "line": 226, + "line": 267, "description": "Map of resource manager folders to create under the root organization. Folder descriptions are unsupported; omit description or leave it empty.", "type": [ "map", @@ -684,7 +697,7 @@ }, { "name": "connectivity", - "line": 272, + "line": 313, "description": "Connectivity configuration including DNS zones, network areas, firewalls, and VPN. Use network_areas with descriptive keys that reflect any business, security, tenant, or regional boundary; network_area and firewall remain supported for existing single-area configurations. Use firewalls for per-area appliance settings.", "type": [ "object", @@ -1282,7 +1295,7 @@ }, { "name": "connectivity_regions", - "line": 425, + "line": 466, "description": "Regional connectivity configuration for the statically supported eu01 and eu02 hubs. Set to null to use the single-region connectivity input.", "type": [ "map", @@ -1297,7 +1310,7 @@ }, { "name": "vpn_pre_shared_keys", - "line": 436, + "line": 477, "description": "Pre-shared keys per connectivity.vpn.connections key, one per tunnel. Kept out of the connectivity object so the topology stays committable; supply through TF_VAR_vpn_pre_shared_keys or a gitignored tfvars file. Minimum 20 characters.", "type": [ "map", @@ -1314,7 +1327,7 @@ }, { "name": "firewall_config", - "line": 450, + "line": 491, "description": "Policy pushed to one OPNsense appliance through its API. Requires connectivity.firewall to be deployed and firewall_api_credentials to be set. Set to null to leave the appliance untouched.", "type": [ "object", @@ -1600,7 +1613,7 @@ }, { "name": "firewall_admin_username", - "line": 538, + "line": 579, "description": "Login used to derive the API key from the appliance. The STACKIT OPNsense image ships with root.", "type": "string", "required": false, @@ -1609,7 +1622,7 @@ }, { "name": "firewall_admin_password", - "line": 544, + "line": 585, "description": "Password of firewall_admin_username, used once to derive the API key. Defaults to the password baked into the STACKIT OPNsense image. Override through TF_VAR_firewall_admin_password after rotating it, or set to null and supply firewall_api_credentials instead.", "type": "string", "required": false, @@ -1617,7 +1630,7 @@ }, { "name": "firewall_bootstrap", - "line": 551, + "line": 592, "description": "Pass -var firewall_bootstrap=true on the two greenfield applies that create the OPNsense API key. It suppresses the Secrets Manager read, which would otherwise fail at plan time because the secret does not exist yet. Never set it on an established deployment: without the bootstrap cache file it plans the destruction of the policy and the stored key. The steady state needs no variable at all.", "type": "bool", "required": false, @@ -1626,7 +1639,7 @@ }, { "name": "firewall_api_secret_version", - "line": 557, + "line": 598, "description": "Write version of the OPNsense API key in the Secrets Manager. The key is only transmitted when this number changes, so bump it by one after a re-bootstrap has produced a fresh key, otherwise the Secrets Manager keeps serving the old one.", "type": "number", "required": false, @@ -1635,7 +1648,7 @@ }, { "name": "firewall_api_credentials", - "line": 563, + "line": 604, "description": "OPNsense API key and secret. Bootstrap them once from the appliance login with src/modules/firewall-config/scripts/bootstrap-api-key.sh, then supply through TF_VAR_firewall_api_credentials or a gitignored tfvars file. Kept out of firewall_config so the policy stays committable.", "type": [ "object", @@ -1649,7 +1662,7 @@ }, { "name": "sandboxes", - "line": 577, + "line": 618, "description": "List of sandboxes to create.", "type": [ "list", @@ -1674,7 +1687,7 @@ }, { "name": "landing_zones", - "line": 591, + "line": 632, "description": "Map of landing zones to create. Public landing zones can create a local network with network_enabled. Corporate landing zones use network_area_key to select a connectivity.network_areas entry; set region to eu01 or eu02 with connectivity_regions.", "type": [ "map", @@ -1790,7 +1803,7 @@ }, { "name": "landing_zone_namespace_services", - "line": 645, + "line": 686, "description": "Map of namespace-service configurations per landing zone key. If a key is present, namespace service is enabled for that landing zone.", "type": [ "map", diff --git a/landing-zone-configurator/app/packages/domain/src/common-validation.ts b/landing-zone-configurator/app/packages/domain/src/common-validation.ts index c1796137..525f4cf5 100644 --- a/landing-zone-configurator/app/packages/domain/src/common-validation.ts +++ b/landing-zone-configurator/app/packages/domain/src/common-validation.ts @@ -6,6 +6,10 @@ import { } from "./common-document.js"; import { objectValue, textValue, type Values } from "./configuration.js"; import { acceleratorInputs, effectiveInput } from "./features.js"; +import { + type ObservabilityPlanCatalogue, + observabilityRetentionFindings, +} from "./observability-plans.js"; export type ConfigurationFinding = { path: string; @@ -27,6 +31,7 @@ export type ConfigurationAssessment = { // A clean report is not deployment authorization or a reachability proof. export function assessCommonConfiguration( document: CommonConfiguration, + catalogue?: ObservabilityPlanCatalogue, ): ConfigurationAssessment { const values = compileCommonConfiguration(document); const areas = commonNetworkAreas(document); @@ -61,6 +66,46 @@ export function assessCommonConfiguration( ) add(input.name, "required", "Diese Angabe ist erforderlich."); } + const bucketNaming = effectiveInput(values, "bucket_naming"); + if (bucketNaming !== "legacy" && bucketNaming !== "project-id") + add( + "bucket_naming", + "invalid-bucket-naming", + "Das Bucket-Namensschema muss project-id oder legacy sein.", + ); + const observability = effectiveInput(values, "observability"); + if (observability != null) { + const current = + catalogue?.region === (textValue(values.region) || "eu01") + ? catalogue + : undefined; + const report = observabilityRetentionFindings( + objectValue(observability), + current, + ); + for (const finding of report.findings) + add( + `observability.${finding.field}`, + finding.code, + finding.message, + undefined, + finding.code === "unverified-observability-retention" + ? "execution" + : "configuration", + finding.code === "unverified-observability-retention" + ? "warning" + : "error", + ); + if (report.capabilities.source !== "live") + add( + "observability.plan_name", + "observability-static-capabilities", + "Tariffähigkeiten werden ganz oder teilweise anhand dokumentierter statischer Regeln geprüft; dies bestätigt keine aktuelle Verfügbarkeit im Zielprojekt.", + undefined, + "execution", + "warning", + ); + } const region = textValue(values.region) || "eu01"; if (!["eu01", "eu02"].includes(region)) add( diff --git a/landing-zone-configurator/app/packages/domain/src/features.ts b/landing-zone-configurator/app/packages/domain/src/features.ts index 0f7c3fc1..14529909 100644 --- a/landing-zone-configurator/app/packages/domain/src/features.ts +++ b/landing-zone-configurator/app/packages/domain/src/features.ts @@ -29,7 +29,12 @@ export const featureGroups = { }, management: { title: "Zentrale Betriebsdienste", - inputs: ["observability", "audit_logs", "federated_identity_providers"], + inputs: [ + "bucket_naming", + "observability", + "audit_logs", + "federated_identity_providers", + ], }, network: { title: "Netzwerkbereiche, DNS und VPN", diff --git a/landing-zone-configurator/app/packages/domain/src/index.ts b/landing-zone-configurator/app/packages/domain/src/index.ts index 4f353ad1..10a33bef 100644 --- a/landing-zone-configurator/app/packages/domain/src/index.ts +++ b/landing-zone-configurator/app/packages/domain/src/index.ts @@ -45,6 +45,7 @@ export * from "./document.js"; export * from "./editor-document.js"; export * from "./features.js"; export * from "./initial-plan.js"; +export * from "./observability-plans.js"; export * from "./project-templates.js"; export * from "./template-parameters.js"; export * from "./tfvars.js"; diff --git a/landing-zone-configurator/app/packages/domain/src/initial-plan.ts b/landing-zone-configurator/app/packages/domain/src/initial-plan.ts index 29a0fa31..3615b30a 100644 --- a/landing-zone-configurator/app/packages/domain/src/initial-plan.ts +++ b/landing-zone-configurator/app/packages/domain/src/initial-plan.ts @@ -44,6 +44,7 @@ export function initialPlanIssues( "sandboxes", "devops", "observability", + "bucket_naming", "audit_logs", "connectivity", ]); diff --git a/landing-zone-configurator/app/packages/domain/src/observability-plans.ts b/landing-zone-configurator/app/packages/domain/src/observability-plans.ts new file mode 100644 index 00000000..29eb5b23 --- /dev/null +++ b/landing-zone-configurator/app/packages/domain/src/observability-plans.ts @@ -0,0 +1,176 @@ +/** Service capabilities, never proof of availability or permission to provision. */ +export type ObservabilityPlanCapabilities = { + metricsRetention?: boolean; + logsRetention?: boolean; + tracesRetention?: boolean; +}; +export type ObservabilityPlanOption = { + value: string; + label: string; + capabilities?: ObservabilityPlanCapabilities; +}; +export type ObservabilityPlanCatalogue = { + region: string; + fetchedAt: string; + observabilityPlans?: { + status: "available" | "unavailable"; + options: ObservabilityPlanOption[]; + }; +}; +export const observabilityRetentionPolicy = { + source: + "https://docs.stackit.cloud/products/logging-and-monitoring/observability/reference/service-plans-observability/", + checkedOn: "2026-10-21", + metricsMaxDays: 780, + logsMaxDays: 30, + tracesMaxDays: 30, +} as const; + +export function observabilityPlanCapabilities( + planName: string, + catalogue?: ObservabilityPlanCatalogue, +) { + let fallback: ObservabilityPlanCapabilities = {}; + if (/^Observability-Monitoring-.+-EU0[12]$/.test(planName)) + fallback = { + metricsRetention: true, + logsRetention: false, + tracesRetention: false, + }; + else if ( + /^Observability-(?:Frontend|Metrics-?Endpoint)(?:-.+)?-EU0[12]$/.test( + planName, + ) + ) + fallback = { + metricsRetention: false, + logsRetention: false, + tracesRetention: false, + }; + else if ( + /^Observability-(?:Observability-)?(?:Starter|Small|Medium|Large|XLarge)-EU0[12]$/.test( + planName, + ) + ) + fallback = { + metricsRetention: true, + logsRetention: true, + tracesRetention: true, + }; + const fetchedAt = Date.parse(catalogue?.fetchedAt ?? ""); + const age = Date.now() - fetchedAt; + const part = + Number.isFinite(age) && age >= -60000 && age <= 15 * 60 * 1000 + ? catalogue?.observabilityPlans + : undefined; + const entry = + part?.status === "available" + ? part.options.find((option) => option.value === planName) + : undefined; + const live = entry?.capabilities ?? {}; + // Live metadata may restrict the reviewed native contract. It cannot enable + // settings which this pinned engine explicitly rejects for a known family. + const capabilities: ObservabilityPlanCapabilities = { ...fallback, ...live }; + let nativeRestriction = false; + for (const field of [ + "metricsRetention", + "logsRetention", + "tracesRetention", + ] as const) + if (fallback[field] === false) { + capabilities[field] = false; + if (live[field] === true) nativeRestriction = true; + } + const liveFields = Object.values(live).filter( + (value) => typeof value === "boolean", + ).length; + return { + ...capabilities, + source: + liveFields === 3 && !nativeRestriction + ? "live" + : liveFields + ? "live-and-static" + : Object.keys(fallback).length + ? "static" + : "unknown", + available: part?.status === "available" ? Boolean(entry) : undefined, + fetchedAt: liveFields ? catalogue?.fetchedAt : undefined, + policy: observabilityRetentionPolicy, + }; +} + +export function observabilityRetentionFindings( + values: Record, + catalogue?: ObservabilityPlanCatalogue, +) { + const planName = + typeof values.plan_name === "string" + ? values.plan_name + : "Observability-Starter-EU01"; + const capabilities = observabilityPlanCapabilities(planName, catalogue); + const findings: { field: string; code: string; message: string }[] = []; + if (capabilities.available === false) + findings.push({ + field: "plan_name", + code: "observability-plan-unavailable", + message: + "Der gewählte Observability-Tarif fehlt im aktuellen Katalog. Tarif prüfen; vorhandene Werte werden beibehalten.", + }); + for (const [field, supported, maximum] of [ + [ + "logs_retention_days", + capabilities.logsRetention, + observabilityRetentionPolicy.logsMaxDays, + ], + [ + "traces_retention_days", + capabilities.tracesRetention, + observabilityRetentionPolicy.tracesMaxDays, + ], + [ + "metrics_retention_days", + capabilities.metricsRetention, + observabilityRetentionPolicy.metricsMaxDays, + ], + [ + "metrics_retention_days_5m_downsampling", + capabilities.metricsRetention, + observabilityRetentionPolicy.metricsMaxDays, + ], + [ + "metrics_retention_days_1h_downsampling", + capabilities.metricsRetention, + observabilityRetentionPolicy.metricsMaxDays, + ], + ] as const) { + const value = values[field]; + if (value == null) continue; + if (supported === false) + findings.push({ + field, + code: "unsupported-observability-retention", + message: + "Der gewählte Observability-Tarif unterstützt diese Aufbewahrungseinstellung nicht. Wert entfernen oder auf null setzen; Tarif nicht automatisch ändern.", + }); + else if (supported === undefined) + findings.push({ + field, + code: "unverified-observability-retention", + message: + "Die Unterstützung dieser Aufbewahrungseinstellung ist weder live noch durch den geprüften Fallback bekannt. Vor Ausführung prüfen.", + }); + if ( + typeof value !== "number" || + !Number.isInteger(value) || + value < 1 || + value > maximum + ) + findings.push({ + field, + code: "invalid-observability-retention", + message: `Die Aufbewahrung muss eine ganze Zahl zwischen 1 und ${maximum} Tagen sein (dokumentierter Servicegrenzwert).`, + }); + } + return { capabilities, findings }; +} diff --git a/landing-zone-configurator/app/scripts/assistant-coverage.ts b/landing-zone-configurator/app/scripts/assistant-coverage.ts index dc0a10b6..6d8ecaa6 100644 --- a/landing-zone-configurator/app/scripts/assistant-coverage.ts +++ b/landing-zone-configurator/app/scripts/assistant-coverage.ts @@ -25,6 +25,7 @@ const inputScenarios: Record = { rm_folders: ["A02"], organization_owners: ["A02"], organization_auditors: ["A02"], + bucket_naming: ["A03", "A16"], observability: ["A03", "A09"], audit_logs: ["A03"], federated_identity_providers: ["A03"], @@ -74,6 +75,7 @@ export function buildAssistantCoverage() { "../packages/domain/src/project-templates.ts", "../packages/domain/src/template-parameters.ts", "../packages/domain/src/common-validation.ts", + "../packages/domain/src/observability-plans.ts", "../packages/domain/src/common-document.ts", "../packages/domain/src/editor-document.ts", "../packages/domain/src/application-catalogue.ts", diff --git a/landing-zone-configurator/app/scripts/assistant-scenarios.ts b/landing-zone-configurator/app/scripts/assistant-scenarios.ts index e6fafe29..b06e9f75 100644 --- a/landing-zone-configurator/app/scripts/assistant-scenarios.ts +++ b/landing-zone-configurator/app/scripts/assistant-scenarios.ts @@ -279,6 +279,22 @@ export const assistantScenarios: readonly AssistantScenario[] = [ "Bitte zentrale Observability als Entwurf einrichten: plan_name Observability-Starter-EU01, ACL 203.0.113.0/24, metrics_retention_days 30.", editorExpected: { path: "observability.metrics_retention_days", value: 30 }, }, + { + id: "editor-monitoring-retention", + topic: "observability", + fixture: "baseline", + message: + "Bitte zentrale Observability als lokalen Entwurf einrichten: plan_name Observability-Monitoring-Medium-EU01, ACL 203.0.113.0/24, metrics_retention_days 30. Logs und Traces sind im Monitoring-Tarif nicht unterstützt, dafür keine Retention setzen. Keine Ausführung.", + editorExpected: { path: "observability.metrics_retention_days", value: 30 }, + }, + { + id: "editor-project-unique-buckets", + topic: "management-state", + fixture: "baseline", + message: + "Bitte Bucket-Namensschema im lokalen Entwurf ausdrücklich auf bucket_naming=project-id setzen, damit die vollständige jeweilige Projekt-ID enthalten ist. Keine sonstigen Änderungen, kein Plan oder Apply.", + editorExpected: { path: "bucket_naming", value: "project-id" }, + }, { id: "editor-devops", topic: "devops-git", @@ -720,6 +736,8 @@ export function assistantScenarioFixture(scenario: AssistantScenario) { draft = createPlatformDraftCopy(draft, crypto.randomUUID()); draft.projectTemplates = []; } + if (scenario.id === "editor-project-unique-buckets") + draft = setCommonInput(draft, "bucket_naming", "legacy"); const values = assistantPublicValues(draft); if (scenario.fixture === "governance-verified-email") values["assistant.user_email"] = "login@example.test"; @@ -856,6 +874,21 @@ export function validateAssistantScenario( ); if (!isDeepStrictEqual(value, scenario.editorExpected.value)) failures.push("editor_expected_value_not_applied"); + if (scenario.topic === "observability") { + const settings = objectValue( + compileCommonConfiguration(candidate).observability, + ); + if ( + settings.logs_retention_days != null || + settings.traces_retention_days != null + ) + failures.push("unrequested_logs_or_traces_retention"); + if ( + scenario.id === "editor-monitoring-retention" && + settings.plan_name !== "Observability-Monitoring-Medium-EU01" + ) + failures.push("monitoring_plan_changed"); + } } catch (error) { if (!(error instanceof Error)) throw error; failures.push("editor_proposal_not_applicable"); diff --git a/landing-zone-configurator/app/scripts/local-stackit-login.ts b/landing-zone-configurator/app/scripts/local-stackit-login.ts index 0dfb83b8..ef9c22e6 100644 --- a/landing-zone-configurator/app/scripts/local-stackit-login.ts +++ b/landing-zone-configurator/app/scripts/local-stackit-login.ts @@ -355,7 +355,7 @@ async function start() { }); platformUpgradeEnabled = runner.supportsAccelerator?.( - "c4b43c36af198985980b17626c48d357795e3fbd", + "9e7fb46c329aca32e878ce1f0ea3b944ea240354", ) === true; plans = new Plans( pool, diff --git a/landing-zone-configurator/app/scripts/prepare-initial-plan-contract.ts b/landing-zone-configurator/app/scripts/prepare-initial-plan-contract.ts index b4c0ccc6..7a53e02f 100644 --- a/landing-zone-configurator/app/scripts/prepare-initial-plan-contract.ts +++ b/landing-zone-configurator/app/scripts/prepare-initial-plan-contract.ts @@ -114,6 +114,7 @@ const configurations: [string, Record][] = [ { ...capabilities, observability: { logs_retention_days: 14, acl: ["192.0.2.0/24"] }, + bucket_naming: "project-id", audit_logs: { s3_object_lock: false, link_scopes: [ diff --git a/landing-zone-configurator/app/scripts/verify-initial-plan-contract.mjs b/landing-zone-configurator/app/scripts/verify-initial-plan-contract.mjs index c722321f..6e6a57b8 100644 --- a/landing-zone-configurator/app/scripts/verify-initial-plan-contract.mjs +++ b/landing-zone-configurator/app/scripts/verify-initial-plan-contract.mjs @@ -138,11 +138,15 @@ for (const event of plans) { if (enabled("observability")) { const instance = resources("stackit_observability_instance")[0].change .after; + // Mock providers synthesize computed retention values. Assert the native + // input contract instead of mistaking a mock zero for a service default. assert.equal( - instance.logs_retention_days, - name === "combined" ? 14 : 30, + event.test_plan.variables.observability.value.logs_retention_days ?? null, + name === "combined" ? 14 : null, name, ); + if (name === "combined") + assert.equal(instance.logs_retention_days, 14, name); assert.deepEqual( instance.acl, name === "combined" ? ["192.0.2.0/24"] : [], diff --git a/landing-zone-configurator/app/tests/assistant-scenarios.test.ts b/landing-zone-configurator/app/tests/assistant-scenarios.test.ts index f3d7a935..161d11cc 100644 --- a/landing-zone-configurator/app/tests/assistant-scenarios.test.ts +++ b/landing-zone-configurator/app/tests/assistant-scenarios.test.ts @@ -230,7 +230,13 @@ it.each(assistantScenarios)( acl: ["203.0.113.0/24"], s3_object_lock: false, }, - observability: { metrics_retention_days: 30 }, + observability: { + metrics_retention_days: 30, + ...(scenario.id === "editor-monitoring-retention" + ? { plan_name: "Observability-Monitoring-Medium-EU01" } + : {}), + }, + bucket_naming: "project-id", devops: { git_flavor: "git-10", allowed_network_ranges: ["203.0.113.0/24"], diff --git a/landing-zone-configurator/app/tests/cloud-catalogues.test.ts b/landing-zone-configurator/app/tests/cloud-catalogues.test.ts index 7067ef33..c64d2b6f 100644 --- a/landing-zone-configurator/app/tests/cloud-catalogues.test.ts +++ b/landing-zone-configurator/app/tests/cloud-catalogues.test.ts @@ -538,7 +538,13 @@ it("loads distinct IaaS bastion options and Observability plan names without sug if (target.includes("argus.api")) return Response.json({ plans: [ - { planId: "not-the-tf-input", name: "Observability-Starter-EU01" }, + { + planId: "not-the-tf-input", + name: "Observability-Starter-EU01", + totalMetricSamples: 100, + logsStorage: 0, + tracesStorage: 0, + }, ], }); if (target.endsWith("/machine-types")) @@ -578,6 +584,11 @@ it("loads distinct IaaS bastion options and Observability plan names without sug { value: "Observability-Starter-EU01", label: "Observability-Starter-EU01", + capabilities: { + metricsRetention: true, + logsRetention: false, + tracesRetention: false, + }, }, ]); expect(result.bastionMachineTypes?.options[0]?.value).toBe("g2i.1"); diff --git a/landing-zone-configurator/app/tests/common-configuration.test.ts b/landing-zone-configurator/app/tests/common-configuration.test.ts index ada6dd9d..ccb326ca 100644 --- a/landing-zone-configurator/app/tests/common-configuration.test.ts +++ b/landing-zone-configurator/app/tests/common-configuration.test.ts @@ -94,12 +94,12 @@ describe("shared accelerator feature contract", () => { landing_zone_configurator: "true", }); }); - it("assigns all 29 inputs exactly once, including every protected input", () => { + it("assigns all 30 inputs exactly once, including every protected input", () => { const assigned = Object.values(featureGroups).flatMap((group) => [ ...group.inputs, ]); - expect(assigned.length).toBe(29); - expect(new Set(assigned).size).toBe(29); + expect(assigned.length).toBe(30); + expect(new Set(assigned).size).toBe(30); expect([...assigned].sort()).toEqual( acceleratorInputs.map((input) => input.name).sort(), ); @@ -489,3 +489,63 @@ describe("combination and execution boundaries", () => { }, ); }); + +it("keeps Monitoring retention nullable and rejects explicit log/trace overrides before execution", () => { + for (const retention of [ + {}, + { logs_retention_days: null, traces_retention_days: null }, + ]) { + const document = setCommonInput(create(), "observability", { + plan_name: "Observability-Monitoring-Medium-EU01", + ...retention, + }); + const effective = objectValue( + effectiveInput(values(document), "observability"), + ); + expect(effective.logs_retention_days ?? null).toBeNull(); + expect(effective.traces_retention_days ?? null).toBeNull(); + expect(effective.metrics_retention_days ?? null).toBeNull(); + expect( + assessCommonConfiguration(document).findings.some( + (finding) => finding.code === "unsupported-observability-retention", + ), + ).toBe(false); + } + const document = setCommonInput(create(), "observability", { + plan_name: "Observability-Monitoring-Medium-EU01", + logs_retention_days: 30, + traces_retention_days: 14, + }); + expect( + assessCommonConfiguration(document) + .findings.filter( + (finding) => finding.code === "unsupported-observability-retention", + ) + .map((finding) => finding.path), + ).toEqual([ + "observability.logs_retention_days", + "observability.traces_retention_days", + ]); + expect(values(document).observability).toMatchObject({ + logs_retention_days: 30, + traces_retention_days: 14, + }); +}); + +it("uses project-qualified bucket naming by default and preserves explicit legacy selection", () => { + expect(effectiveInput(values(create()), "bucket_naming")).toBe("project-id"); + const legacy = setCommonInput(create(), "bucket_naming", "legacy"); + expect(values(legacy).bucket_naming).toBe("legacy"); + expect( + assessCommonConfiguration(legacy).findings.some( + (finding) => finding.code === "invalid-bucket-naming", + ), + ).toBe(false); + const invalid = setCommonInput(create(), "bucket_naming", "unsupported"); + expect(assessCommonConfiguration(invalid).findings).toContainEqual( + expect.objectContaining({ + path: "bucket_naming", + code: "invalid-bucket-naming", + }), + ); +}); diff --git a/landing-zone-configurator/app/tests/local-runner.test.ts b/landing-zone-configurator/app/tests/local-runner.test.ts index 1aeced6a..fceb40f5 100644 --- a/landing-zone-configurator/app/tests/local-runner.test.ts +++ b/landing-zone-configurator/app/tests/local-runner.test.ts @@ -80,7 +80,7 @@ async function fixture( it("binds the explicit platform upgrade source into the package identity and rejects unknown sources", async () => { const { root, jobs, runner } = await fixture(); - const upgrade = "c4b43c36af198985980b17626c48d357795e3fbd"; + const upgrade = "9e7fb46c329aca32e878ce1f0ea3b944ea240354"; expect(runner.supportsAccelerator(upgrade)).toBe(false); await writeFile( join(root, "platform-source.json"), diff --git a/landing-zone-configurator/app/tests/observability-plans.test.ts b/landing-zone-configurator/app/tests/observability-plans.test.ts new file mode 100644 index 00000000..61a8cc0b --- /dev/null +++ b/landing-zone-configurator/app/tests/observability-plans.test.ts @@ -0,0 +1,165 @@ +import { + type ObservabilityPlanCatalogue, + observabilityPlanCapabilities, + observabilityRetentionFindings, +} from "@lzc/domain"; +import { expect, it } from "vitest"; + +const live = ( + value: string, + capabilities: { + metricsRetention?: boolean; + logsRetention?: boolean; + tracesRetention?: boolean; + }, +): ObservabilityPlanCatalogue => ({ + region: "eu01", + fetchedAt: new Date().toISOString(), + observabilityPlans: { + status: "available", + options: [{ value, label: value, capabilities }], + }, +}); +it("rejects the failed Monitoring/logs combination offline and retains the supplied value", () => { + const settings = { + plan_name: "Observability-Monitoring-Medium-EU01", + logs_retention_days: 30, + metrics_retention_days: 90, + }; + const result = observabilityRetentionFindings(settings); + expect(result.findings.map((finding) => finding.field)).toEqual([ + "logs_retention_days", + ]); + expect(settings.logs_retention_days).toBe(30); + expect(result.capabilities.source).toBe("static"); +}); +it("uses explicit live zero capacities over a conflicting static plan family", () => { + const plan = "Observability-Starter-EU01"; + const result = observabilityRetentionFindings( + { plan_name: plan, logs_retention_days: 5 }, + live(plan, { + metricsRetention: true, + logsRetention: false, + tracesRetention: false, + }), + ); + expect(result.capabilities.source).toBe("live"); + expect(result.findings).toContainEqual( + expect.objectContaining({ + field: "logs_retention_days", + code: "unsupported-observability-retention", + }), + ); +}); +it("accepts a new live plan and never fabricates missing capabilities", () => { + const name = "new-plan"; + expect( + observabilityRetentionFindings( + { plan_name: name, logs_retention_days: 7 }, + live(name, { logsRetention: true }), + ).findings, + ).toEqual([]); + const unknown = observabilityRetentionFindings( + { plan_name: name, traces_retention_days: 7 }, + live(name, { logsRetention: true }), + ); + expect(unknown.findings).toContainEqual( + expect.objectContaining({ + field: "traces_retention_days", + code: "unverified-observability-retention", + }), + ); + expect(unknown.capabilities.tracesRetention).toBeUndefined(); +}); +it("falls back per field and refuses stale/future capability claims", () => { + const name = "Observability-Monitoring-Medium-EU01"; + const partial = observabilityPlanCapabilities( + name, + live(name, { metricsRetention: true }), + ); + expect(partial).toMatchObject({ + source: "live-and-static", + logsRetention: false, + }); + for (const offset of [-16 * 60000, 2 * 60000]) { + const catalogue = live(name, { logsRetention: true }); + catalogue.fetchedAt = new Date(Date.now() + offset).toISOString(); + expect(observabilityPlanCapabilities(name, catalogue)).toMatchObject({ + source: "static", + logsRetention: false, + }); + } +}); +it("reports absence in a fresh catalogue and permits nullable unsupported options", () => { + const result = observabilityRetentionFindings({ + plan_name: "Observability-Frontend-Starter-EU01", + logs_retention_days: null, + metrics_retention_days: null, + }); + expect(result.findings).toEqual([]); + expect(result.capabilities.metricsRetention).toBe(false); + expect( + observabilityRetentionFindings( + { plan_name: "missing" }, + live("present", {}), + ).findings[0]?.code, + ).toBe("observability-plan-unavailable"); +}); +it.each([0, -1, 1.5, 31])( + "rejects out-of-contract logs retention %s", + (value) => { + expect( + observabilityRetentionFindings({ logs_retention_days: value }).findings, + ).toContainEqual( + expect.objectContaining({ code: "invalid-observability-retention" }), + ); + }, +); +it("keeps explicit valid metrics and signals separate", () => { + expect( + observabilityRetentionFindings({ + logs_retention_days: 30, + traces_retention_days: 1, + metrics_retention_days: 780, + }).findings, + ).toEqual([]); + expect( + observabilityRetentionFindings({ metrics_retention_days: 781 }).findings, + ).toContainEqual( + expect.objectContaining({ + field: "metrics_retention_days", + code: "invalid-observability-retention", + }), + ); +}); + +it("does not enable an option rejected by the reviewed native execution contract", () => { + const name = "Observability-Monitoring-Medium-EU01"; + const report = observabilityPlanCapabilities( + name, + live(name, { + metricsRetention: true, + logsRetention: true, + tracesRetention: true, + }), + ); + expect(report).toMatchObject({ + source: "live-and-static", + logsRetention: false, + tracesRetention: false, + }); +}); + +it("recognizes the API Metrics-Endpoint plan spelling in the static fallback", () => { + const report = observabilityRetentionFindings({ + plan_name: "Observability-Metrics-Endpoint-100k-EU01", + metrics_retention_days: 90, + }); + expect(report.capabilities).toMatchObject({ + source: "static", + metricsRetention: false, + }); + expect(report.findings).toContainEqual( + expect.objectContaining({ code: "unsupported-observability-retention" }), + ); +}); diff --git a/landing-zone-configurator/app/tests/platform-plan.test.ts b/landing-zone-configurator/app/tests/platform-plan.test.ts index 405dd191..0f5ecaef 100644 --- a/landing-zone-configurator/app/tests/platform-plan.test.ts +++ b/landing-zone-configurator/app/tests/platform-plan.test.ts @@ -173,6 +173,7 @@ describe.skipIf(!enabled)( applicationContracts = false, services = false, firewall = false, + platformRoot = false, ) { let owner = await session(); const organizationId = randomUUID(), @@ -258,7 +259,12 @@ describe.skipIf(!enabled)( }; const keyId = randomUUID(); const manifest = preparationManifest( - { source: "database", configurationId, revision: 1, credentialId }, + { + source: "database", + configurationId, + revision: 1, + credentialId, + }, { document, head: "1", @@ -340,7 +346,10 @@ describe.skipIf(!enabled)( firewallPlansEnabled: firewall, supportsAccelerator: vi.fn( (commit: string) => - commit === "a256f6896d11134fdc351786f1be5eba4e56b2e2", + commit === + (platformRoot + ? platformUpgradeAcceleratorCommit + : "a256f6896d11134fdc351786f1be5eba4e56b2e2"), ), supportsArtifact: vi.fn(() => true), output: vi.fn( @@ -436,8 +445,11 @@ describe.skipIf(!enabled)( execution, token, ); - async function plan(overrides: object = {}) { - const run = await plans.start(owner, token, preparationId); + async function plan( + overrides: object = {}, + targetPreparationId = preparationId, + ) { + const run = await plans.start(owner, token, targetPreparationId); const ticket = tickets.get(run.id)!; await plans.input(ticket); await plans.stage(ticket, "validating"); @@ -481,6 +493,79 @@ describe.skipIf(!enabled)( }; } + it("blocks live-incompatible retention before preparing and uses reviewed fallback when catalogue loading fails", async () => { + const test = await fixture(false, true); + if (test.manifest.configuration.schemaVersion !== 3) + throw new Error("Expected common configuration fixture"); + const document = editCommonInput( + test.manifest.configuration, + "observability", + { + plan_name: "Observability-Starter-EU01", + logs_retention_days: 7, + }, + ); + await migration!.query( + "UPDATE lzc.configurations SET document=$2 WHERE id=$1", + [test.configurationId, JSON.stringify(recordDraft(document))], + ); + const unavailable = { status: "unavailable" as const, options: [] }; + const automatic = vi.fn(async () => ({ + region: "eu01", + projectId: null, + fetchedAt: new Date().toISOString(), + gitFlavors: unavailable, + vpnPlans: unavailable, + kubernetesVersions: unavailable, + machineTypes: unavailable, + availabilityZones: unavailable, + volumeTypes: unavailable, + observabilityPlans: { + status: "available" as const, + options: [ + { + value: "Observability-Starter-EU01", + label: "Starter", + capabilities: { + metricsRetention: true, + logsRetention: false, + tracesRetention: true, + }, + }, + ], + }, + })); + const preparations = new Preparations( + pool, + test.repositories, + test.profiles, + false, + undefined, + { automatic }, + ); + const request = { + source: "database", + configurationId: test.configurationId, + revision: 1, + credentialId: test.credentialId, + } as const; + await expect( + preparations.create(test.owner, null, request), + ).rejects.toMatchObject({ + code: "observability_plan_incompatible", + }); + expect(await preparations.list(test.owner)).toHaveLength(1); + expect(test.runner.start).not.toHaveBeenCalled(); + automatic.mockRejectedValueOnce(new Error("catalogue unavailable")); + const prepared = await preparations.create(test.owner, null, request); + expect( + (await preparations.list(test.owner)).find( + (item) => item.id === prepared.id, + )?.manifest.configuration, + ).toEqual(document); + expect(test.runner.start).not.toHaveBeenCalled(); + }); + it("prepares and dispatches saved initial service inputs without bypassing tenant, ticket or approval gates", async () => { const test = await fixture(false, true); const preparations = new Preparations( @@ -1751,9 +1836,30 @@ describe.skipIf(!enabled)( ).rejects.toBeDefined(); }); - async function failedCheckpoint() { - const test = await fixture(); - const saved = await test.plan(); + async function failedCheckpoint(platformRoot = false) { + const test = await fixture(false, platformRoot, false, platformRoot); + const currentPreparation = platformRoot + ? await new Preparations( + pool, + test.repositories, + test.profiles, + true, + platformUpgradeAcceleratorCommit, + ).create(test.owner, null, { + source: "database", + configurationId: test.configurationId, + revision: 1, + credentialId: test.credentialId, + platformUpgrade: { + confirm: true, + acceleratorCommit: platformUpgradeAcceleratorCommit, + }, + }) + : undefined; + const saved = await test.plan( + {}, + currentPreparation?.id ?? test.preparationId, + ); const run = await test.plans.apply( test.owner, test.token, @@ -1788,7 +1894,21 @@ describe.skipIf(!enabled)( errorCode: "apply_failed", }); const checkpoint = await test.plans.checkpoint(test.owner, run.id); - return { ...test, saved, run, checkpoint }; + return { + ...test, + manifest: currentPreparation + ? ( + await new Preparations( + pool, + test.repositories, + test.profiles, + ).list(test.owner) + ).find((item) => item.id === currentPreparation.id)?.manifest + : test.manifest, + saved, + run, + checkpoint, + }; } it.each(["bootstrap", "migration"])( @@ -2245,6 +2365,78 @@ describe.skipIf(!enabled)( }, ); + it("allows a reviewed current-root bootstrap correction only for the exact reconciled checkpoint without changing state", async () => { + const test = await failedCheckpoint(true); + const preparations = new Preparations( + pool, + test.repositories, + test.profiles, + true, + platformUpgradeAcceleratorCommit, + ); + const request = { + source: "database" as const, + configurationId: test.configurationId, + revision: 1, + credentialId: test.credentialId, + platformUpgrade: { + confirm: true as const, + acceleratorCommit: platformUpgradeAcceleratorCommit, + }, + } as const; + await expect( + preparations.create(test.owner, null, request), + ).rejects.toMatchObject({ code: "platform_upgrade_state_unavailable" }); + await test.plans.reconcile(test.owner, test.run.id, { + confirmRetainState: true, + stateVersion: test.checkpoint.stateVersion, + checkpointSha256: test.checkpoint.checkpointSha256, + }); + const key = stableStateKey(test.owner, test.manifest); + const before = ( + await migration!.query( + "SELECT version,ciphertext,backend_id,lock_run_id FROM lzc.platform_states WHERE state_key=$1", + [key], + ) + ).rows; + const starts = test.runner.start.mock.calls.length; + const prepared = await preparations.create(test.owner, null, request); + expect( + (await preparations.list(test.owner)).find( + (item) => item.id === prepared.id, + )?.manifest.accelerator.commit, + ).toBe(platformUpgradeAcceleratorCommit); + expect( + ( + await migration!.query( + "SELECT version,ciphertext,backend_id,lock_run_id FROM lzc.platform_states WHERE state_key=$1", + [key], + ) + ).rows, + ).toEqual(before); + expect(test.runner.start).toHaveBeenCalledTimes(starts); + const fresh = await test.plans.start(test.owner, test.token, prepared.id); + expect( + ( + await migration!.query( + "SELECT state_key,state_version FROM lzc.plan_runs WHERE id=$1", + [fresh.id], + ) + ).rows[0], + ).toMatchObject({ + state_key: key, + state_version: test.checkpoint.stateVersion, + }); + await test.plans.cancel(test.owner, fresh.id); + await migration!.query( + "UPDATE lzc.platform_states SET version=version+1 WHERE state_key=$1", + [key], + ); + await expect( + preparations.create(test.owner, null, request), + ).rejects.toMatchObject({ code: "platform_upgrade_state_unavailable" }); + }); + it("reconciles only explicitly reviewed checkpoints once and preserves state for a corrected fresh plan", async () => { const test = await failedCheckpoint(); expect(test.checkpoint.canResume).toBe(true); diff --git a/landing-zone-configurator/deploy/runner/package.sh b/landing-zone-configurator/deploy/runner/package.sh index 6cebb163..8b6b1407 100644 --- a/landing-zone-configurator/deploy/runner/package.sh +++ b/landing-zone-configurator/deploy/runner/package.sh @@ -40,7 +40,7 @@ cp "$tofu_bin" "$runner_dir/tools/tofu" cp ../deploy/runner/run-plan.sh ../deploy/runner/runner.tfrc "$runner_dir/" platform_commit=a256f6896d11134fdc351786f1be5eba4e56b2e2 if [[ "${LZC_PACKAGE_PLATFORM_UPGRADE_ROOT:-false}" == "true" ]]; then - platform_commit=c4b43c36af198985980b17626c48d357795e3fbd + platform_commit=9e7fb46c329aca32e878ce1f0ea3b944ea240354 else git fetch --no-tags https://github.com/stackitcloud/stackit-landing-zone.git "$platform_commit" fi diff --git a/landing-zone-configurator/docs/accelerator-inputs.json b/landing-zone-configurator/docs/accelerator-inputs.json index 5118be0a..48556697 100644 --- a/landing-zone-configurator/docs/accelerator-inputs.json +++ b/landing-zone-configurator/docs/accelerator-inputs.json @@ -1,10 +1,22 @@ { "schemaVersion": 1, - "sourceSha256": "a7bdc9a31e93d355e228072c743b5d85a1dae956d3676cd9079ab904e8ee6f23", + "sourceSha256": "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", "variables": [ + { + "name": "bucket_naming", + "line": 1, + "description": "Object Storage bucket naming scheme: legacy preserves existing names; project-id includes the complete owning project UUID for STACKIT-wide uniqueness. Changing this for existing buckets requires a reviewed migration.", + "type": "string", + "required": false, + "sensitive": false, + "default": "project-id", + "validations": [ + "validation {\n condition = contains([\"legacy\", \"project-id\"], var.bucket_naming)\n error_message = \"Bucket naming must be legacy or project-id.\"\n }" + ] + }, { "name": "owner_email", - "line": 5, + "line": 16, "description": "Email address of the owner. Required for STACKIT resource manager.", "type": "string", "required": true, @@ -12,7 +24,7 @@ }, { "name": "company_name", - "line": 10, + "line": 21, "description": "Name of the company.", "type": "string", "required": true, @@ -20,7 +32,7 @@ }, { "name": "company_code", - "line": 15, + "line": 26, "description": "Company code used in resource naming conventions.", "type": "string", "required": true, @@ -28,7 +40,7 @@ }, { "name": "organization_id", - "line": 20, + "line": 31, "description": "Container ID of the root organization.", "type": "string", "required": true, @@ -36,7 +48,7 @@ }, { "name": "rm_folder_parent_id", - "line": 25, + "line": 36, "description": "ID of the parent folder under which the resource manager folders will be created. If not provided, the resource manager folders will be created under the organization.", "type": "string", "required": false, @@ -45,7 +57,7 @@ }, { "name": "region", - "line": 31, + "line": 42, "description": "STACKIT region for regional resources.", "type": "string", "required": false, @@ -54,7 +66,7 @@ }, { "name": "platform_kubernetes_kube_config_override", - "line": 37, + "line": 48, "description": "Optional raw kubeconfig used by kubernetes/helm platform providers when no platform_kubernetes module output is available.", "type": "string", "required": false, @@ -62,7 +74,7 @@ }, { "name": "platform_contract_namespace", - "line": 44, + "line": 55, "description": "Optional stable UUID namespace for Application handoff; defaults to the organization ID for independent CLI use. This is metadata, not an authorization boundary.", "type": "string", "required": false, @@ -74,7 +86,7 @@ }, { "name": "labels", - "line": 55, + "line": 66, "description": "Additional labels to apply to all resources.", "type": [ "map", @@ -86,7 +98,7 @@ }, { "name": "organization_owners", - "line": 61, + "line": 72, "description": "List of organization owners.", "type": [ "list", @@ -98,7 +110,7 @@ }, { "name": "organization_auditors", - "line": 67, + "line": 78, "description": "List of organization auditors.", "type": [ "list", @@ -110,7 +122,7 @@ }, { "name": "devops", - "line": 73, + "line": 84, "description": "DevOps module configuration. Set to null to skip deployment.", "type": [ "object", @@ -140,7 +152,7 @@ }, { "name": "platform_kubernetes", - "line": 82, + "line": 93, "description": "Map of central, region-scoped platform Kubernetes deployments. Empty map skips deployment.", "type": [ "map", @@ -504,8 +516,8 @@ }, { "name": "observability", - "line": 184, - "description": "Observability instance configuration for the management module. Set to null to skip observability deployment.", + "line": 195, + "description": "Observability instance configuration for the management module. Omitted or null retention uses the service plan defaults. Monitoring plans require both retention fields to be omitted or null. Set to null to skip observability deployment.", "type": [ "object", { @@ -536,18 +548,19 @@ "nestedDefaults": { "values": { "acl": [], - "logs_retention_days": 30, - "metrics_retention_days": 90, - "metrics_retention_days_1h_downsampling": 90, - "metrics_retention_days_5m_downsampling": 90, - "plan_name": "Observability-Starter-EU01", - "traces_retention_days": 30 + "plan_name": "Observability-Starter-EU01" } - } + }, + "validations": [ + "validation {\n condition = var.observability == null ? true : (\n !startswith(var.observability.plan_name, \"Observability-Monitoring-\") ||\n (var.observability.logs_retention_days == null \u0026\u0026 var.observability.traces_retention_days == null)\n )\n error_message = \"Monitoring plans do not support logs or traces retention. Omit logs_retention_days and traces_retention_days or set them to null.\"\n }", + "validation {\n condition = var.observability == null ? true : (\n !can(regex(\"^Observability-(Frontend|Metrics-?Endpoint)\", var.observability.plan_name)) ||\n alltrue([for value in [var.observability.logs_retention_days, var.observability.traces_retention_days, var.observability.metrics_retention_days, var.observability.metrics_retention_days_5m_downsampling, var.observability.metrics_retention_days_1h_downsampling] : value == null])\n )\n error_message = \"Frontend and MetricsEndpoint plans do not support retention overrides. Omit all retention fields or set them to null.\"\n }", + "validation {\n condition = var.observability == null ? true : alltrue([\n for value in [var.observability.logs_retention_days, var.observability.traces_retention_days] : value == null ? true : value \u003e= 1 \u0026\u0026 value \u003c= 30 \u0026\u0026 floor(value) == value\n ])\n error_message = \"Logs and traces retention must be omitted, null or a whole number from 1 to 30 days.\"\n }", + "validation {\n condition = var.observability == null ? true : alltrue([\n for value in [var.observability.metrics_retention_days, var.observability.metrics_retention_days_5m_downsampling, var.observability.metrics_retention_days_1h_downsampling] : value == null ? true : value \u003e= 1 \u0026\u0026 value \u003c= 780 \u0026\u0026 floor(value) == value\n ])\n error_message = \"Metrics retention must be omitted, null or a whole number from 1 to 780 days.\"\n }" + ] }, { "name": "audit_logs", - "line": 198, + "line": 239, "description": "Audit log routing for the management module. A Telemetry Link streams audit events into a Telemetry Router, which forwards them to a Logs instance and archives them to object storage. link_scopes defaults to a single organization-wide link. retention_days applies to both the Logs instance and, when s3_object_lock is enabled, the archive bucket's default retention. s3_object_lock is off by default and turns on GOVERNANCE-mode WORM protection for the archive bucket, which is not cleanly reversible. Set to null to skip audit logs deployment.", "type": [ "object", @@ -592,7 +605,7 @@ }, { "name": "federated_identity_providers", - "line": 212, + "line": 253, "description": "List of federated identity providers to configure for the management service account.", "type": [ "list", @@ -621,7 +634,7 @@ }, { "name": "rm_folders", - "line": 226, + "line": 267, "description": "Map of resource manager folders to create under the root organization. Folder descriptions are unsupported; omit description or leave it empty.", "type": [ "map", @@ -684,7 +697,7 @@ }, { "name": "connectivity", - "line": 272, + "line": 313, "description": "Connectivity configuration including DNS zones, network areas, firewalls, and VPN. Use network_areas with descriptive keys that reflect any business, security, tenant, or regional boundary; network_area and firewall remain supported for existing single-area configurations. Use firewalls for per-area appliance settings.", "type": [ "object", @@ -1282,7 +1295,7 @@ }, { "name": "connectivity_regions", - "line": 425, + "line": 466, "description": "Regional connectivity configuration for the statically supported eu01 and eu02 hubs. Set to null to use the single-region connectivity input.", "type": [ "map", @@ -1297,7 +1310,7 @@ }, { "name": "vpn_pre_shared_keys", - "line": 436, + "line": 477, "description": "Pre-shared keys per connectivity.vpn.connections key, one per tunnel. Kept out of the connectivity object so the topology stays committable; supply through TF_VAR_vpn_pre_shared_keys or a gitignored tfvars file. Minimum 20 characters.", "type": [ "map", @@ -1314,7 +1327,7 @@ }, { "name": "firewall_config", - "line": 450, + "line": 491, "description": "Policy pushed to one OPNsense appliance through its API. Requires connectivity.firewall to be deployed and firewall_api_credentials to be set. Set to null to leave the appliance untouched.", "type": [ "object", @@ -1600,7 +1613,7 @@ }, { "name": "firewall_admin_username", - "line": 538, + "line": 579, "description": "Login used to derive the API key from the appliance. The STACKIT OPNsense image ships with root.", "type": "string", "required": false, @@ -1609,7 +1622,7 @@ }, { "name": "firewall_admin_password", - "line": 544, + "line": 585, "description": "Password of firewall_admin_username, used once to derive the API key. Defaults to the password baked into the STACKIT OPNsense image. Override through TF_VAR_firewall_admin_password after rotating it, or set to null and supply firewall_api_credentials instead.", "type": "string", "required": false, @@ -1617,7 +1630,7 @@ }, { "name": "firewall_bootstrap", - "line": 551, + "line": 592, "description": "Pass -var firewall_bootstrap=true on the two greenfield applies that create the OPNsense API key. It suppresses the Secrets Manager read, which would otherwise fail at plan time because the secret does not exist yet. Never set it on an established deployment: without the bootstrap cache file it plans the destruction of the policy and the stored key. The steady state needs no variable at all.", "type": "bool", "required": false, @@ -1626,7 +1639,7 @@ }, { "name": "firewall_api_secret_version", - "line": 557, + "line": 598, "description": "Write version of the OPNsense API key in the Secrets Manager. The key is only transmitted when this number changes, so bump it by one after a re-bootstrap has produced a fresh key, otherwise the Secrets Manager keeps serving the old one.", "type": "number", "required": false, @@ -1635,7 +1648,7 @@ }, { "name": "firewall_api_credentials", - "line": 563, + "line": 604, "description": "OPNsense API key and secret. Bootstrap them once from the appliance login with src/modules/firewall-config/scripts/bootstrap-api-key.sh, then supply through TF_VAR_firewall_api_credentials or a gitignored tfvars file. Kept out of firewall_config so the policy stays committable.", "type": [ "object", @@ -1649,7 +1662,7 @@ }, { "name": "sandboxes", - "line": 577, + "line": 618, "description": "List of sandboxes to create.", "type": [ "list", @@ -1674,7 +1687,7 @@ }, { "name": "landing_zones", - "line": 591, + "line": 632, "description": "Map of landing zones to create. Public landing zones can create a local network with network_enabled. Corporate landing zones use network_area_key to select a connectivity.network_areas entry; set region to eu01 or eu02 with connectivity_regions.", "type": [ "map", @@ -1790,7 +1803,7 @@ }, { "name": "landing_zone_namespace_services", - "line": 645, + "line": 686, "description": "Map of namespace-service configurations per landing zone key. If a key is present, namespace service is enabled for that landing zone.", "type": [ "map", diff --git a/landing-zone-configurator/docs/assistant-feature-coverage.json b/landing-zone-configurator/docs/assistant-feature-coverage.json index f57fe0fc..db79a10a 100644 --- a/landing-zone-configurator/docs/assistant-feature-coverage.json +++ b/landing-zone-configurator/docs/assistant-feature-coverage.json @@ -3,12 +3,13 @@ "engineRevision": "a256f6896d11134fdc351786f1be5eba4e56b2e2", "status": "concept-coverage-not-ai-qualification", "sourceDigests": { - "packages/domain/src/features.ts": "6f99918f825b2891db70ba023284a8d1ae529965c56d06445a9108fb0d791138", - "packages/domain/src/accelerator-inputs.json": "6c7c17d9fe550e4f9e1ee41db4463b6ed4840b63e4e01620b99626c8ff8737de", + "packages/domain/src/features.ts": "bf2506a3ba0bff45ce87c010a4a86e119dd07b0b9216dfea88e34e71d17c9ba6", + "packages/domain/src/accelerator-inputs.json": "5bc4c00a4be5546e44208ec26cc51a5465b4407fbea07f7e3edb807438bab501", "packages/domain/src/catalogue.json": "0c9fc73cbfff4eb0ea2e9956165646e409fbff107cbf9ca1eb3b78a25dca2059", "packages/domain/src/project-templates.ts": "6629d6cf5e557682b181bc9aca99d496d493e9ae9070e875f72ff608f0642459", "packages/domain/src/template-parameters.ts": "ea2cb1e272f4f51da6bc68ea78895febe128b83f2b3022371a5aeeb666120e0a", - "packages/domain/src/common-validation.ts": "12b1a51aef9055e0146f41fa30f9ba03de0e6e078f78597cc488da9884f0761f", + "packages/domain/src/common-validation.ts": "27b7edb026926aef1b6efb4f8e2c131d43a37afb2abaa78f0d600006a5f4a492", + "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": "2e1dfe53840775f5acd6212b783d7823673c71620c868abf919dae7e3a0b798a", @@ -99,6 +100,13 @@ { "id": "management", "inputs": [ + { + "input": "bucket_naming", + "scenarios": [ + "A03", + "A16" + ] + }, { "input": "observability", "scenarios": [ @@ -246,6 +254,20 @@ } ], "fields": [ + { + "path": "bucket_naming", + "group": "management", + "sensitive": false, + "type": "string", + "optional": true, + "commonEditor": "form", + "assistantMode": "analyze-question-propose-validated-diff", + "scenarios": [ + "A03", + "A16" + ], + "qualification": "not-qualified" + }, { "path": "owner_email", "group": "identity", diff --git a/landing-zone-configurator/docs/assistant-knowledge/management-state.de.md b/landing-zone-configurator/docs/assistant-knowledge/management-state.de.md index 3a7d3df7..6035118d 100644 --- a/landing-zone-configurator/docs/assistant-knowledge/management-state.de.md +++ b/landing-zone-configurator/docs/assistant-knowledge/management-state.de.md @@ -30,6 +30,15 @@ Backend-Bootstrap/-Konfiguration ist eine getrennte Voraussetzung nativer Ausfuehrung, keine in exportierte Konfiguration eingebettete Configurator-Laufzeitabhaengigkeit. + +`bucket_naming` verwendet standardmäßig `project-id`: Alle Management- und +Workload-Buckets enthalten die vollständige ID ihres Projekts und unterschiedliche +Zwecksuffixe, höchstens 63 Zeichen lang. Der Namensraum gilt STACKIT-weit. +`legacy` ist eine ausdrückliche Kompatibilitätswahl für vorhandene Bucket-Namen. +Ein Wechsel bei bestehenden Buckets erfordert eine geprüfte Migration oder +Ersetzungsentscheidung. Nach einem fehlgeschlagenen Apply ohne Buckets können +vorhandenes Projekt und State mit den neuen Namen weiterverwendet werden. + ## Abhaengigkeiten @@ -68,6 +77,13 @@ Disaster Recovery. Niemals Remote-State als Chat-Aktion lesen, abrufen oder veraendern. Plan, Apply, Destroy, Recovery und Veroeffentlichung haben eigene Autorisierungsgates; kein gezielter Terraform-Workaround. +Eine Quellkorrektur darf einen fehlgeschlagenen Bootstrap-Checkpoint nur nach +explizitem Abgleich seiner exakten State-Version durch den Eigentümer verwenden, +mit unterstütztem festgelegtem Quellstand und ohne Sperre, Migration, separaten +Recovery-State oder aktiven Lauf. Eine neue Vorbereitung und einen neu geprüften +Plan verwenden; niemals mit leerem State beginnen oder den verbrauchten Plan +wiederholen. + ## Zugangsdaten diff --git a/landing-zone-configurator/docs/assistant-knowledge/management-state.md b/landing-zone-configurator/docs/assistant-knowledge/management-state.md index f0be8757..f4297345 100644 --- a/landing-zone-configurator/docs/assistant-knowledge/management-state.md +++ b/landing-zone-configurator/docs/assistant-knowledge/management-state.md @@ -2,55 +2,52 @@ ## Purpose -Provide the platform's management project, Terraform state storage and base -operating identities. Distinguish these from optional monitoring services and -from the backend that stores the current Accelerator deployment's own state. +Management project, state storage and operating identities; distinguish optional +monitoring and the backend of the current Accelerator deployment. ## Accelerator Contract -The root calls module.management unconditionally beneath the governance -platform folder. Ownership, naming, organization and labels are derived from -root inputs. Management implementation determines state-bucket and identity -resources; it has no independent invented management_enabled switch. -The module also creates its Secrets Manager foundation; do not equate that -instance with every workload's separate secretsmanager_enabled choice. Backend -bootstrap/configuration is a separate native execution prerequisite, not a -runtime Configurator dependency embedded into exported configuration. +module.management always exists under the governance platform folder; no +management_enabled switch. Root inputs derive ownership, naming and labels. +Secrets Manager is a foundation, independent of workload secretsmanager_enabled. +Backend bootstrap/configuration is a separate execution prerequisite. + +bucket_naming defaults to project-id: all management/workload bucket names contain +the full owning project UUID and distinct purpose suffixes within 63 characters. +STACKIT names are global. Explicit legacy preserves existing bucket names; never +switch existing buckets without reviewing replacement/migration. A failed Apply +with no buckets can keep its original project/state and use project-id naming. ## Dependencies -Governance/naming and credential foundations. Observability, audit routing and -federation are optional settings attached to management. Their knowledge -dependencies do not make those services required. +Governance, naming and credentials. Monitoring, audit and federation stay optional. ## Dialogue -Explain that "only SNA first" scopes the desired configuration change, not a -targeted cloud operation: a full native apply still includes mandatory base -resources and other enabled inputs. Ask about backend ownership, lifecycle, -retention and operating responsibility without requesting state or passwords. +“Only SNA first” scopes the draft change, not a targeted cloud operation. Full +native Apply includes management and other enabled inputs. Discuss ownership, +lifecycle and recovery without requesting state or credentials. ## Examples -Synthetic baseline: observability = null, audit_logs = null, -federated_identity_providers = []. Optional services are disabled but management -and governance remain part of the root. This is a fragment, not a complete run. +observability=null, audit_logs=null, federated_identity_providers=[] disables +optional services, not management/governance. This is not a complete run. ## Limits -A created state bucket does not prove a configured backend, successful state -migration, encryption, lock safety or disaster recovery. Never read/pull/mutate -remote state as a chat action. Plan, apply, destroy, recovery and publication -have independent authorization gates; no targeted Terraform workaround. +A bucket proves no backend setup, migration, encryption, locking or recovery. +Never pull/mutate state as a chat action. Plan, Apply, Destroy, recovery and +publication require separate authorization. No targeted Terraform workaround. +A source correction may reuse a failed Bootstrap checkpoint only after explicit +owner reconciliation of its exact state version, with supported pinned provenance +and no lock, migration, separate recovery or active run. Use a new preparation +and reviewed Plan; never restart with empty state or retry the consumed Plan. ## Credentials -Provide backend/object-storage and provider credentials through approved -external execution bindings. Keys, passphrases, state contents and plan files -must never enter model context or a knowledge file. +Use approved external bindings. No keys, passphrases, state or plans in chat. ## Sources -Root and management implementation plus deployment-credential documentation -are fingerprinted in the manifest. Re-review resource/bootstrap changes before -regeneration; no source snapshot proves operational recovery qualification. \ No newline at end of file +Fingerprint root/management and credential docs. Re-review before regeneration; +source snapshots do not qualify operational recovery. diff --git a/landing-zone-configurator/docs/assistant-knowledge/manifest.json b/landing-zone-configurator/docs/assistant-knowledge/manifest.json index 665f594c..500175a1 100644 --- a/landing-zone-configurator/docs/assistant-knowledge/manifest.json +++ b/landing-zone-configurator/docs/assistant-knowledge/manifest.json @@ -25,7 +25,10 @@ "externalSources": [ "https://docs.stackit.cloud/platform/access-and-identity/service-accounts/how-tos/manage-service-account-keys/" ], - "scenarios": ["A16", "A23"] + "scenarios": [ + "A16", + "A23" + ] }, { "id": "stackit-network-components", @@ -33,10 +36,18 @@ "version": 3, "checkedOn": "2026-10-12", "qualification": "local-draft-not-reviewed-release", - "groups": ["network"], + "groups": [ + "network" + ], "mandatory": false, - "inputs": ["connectivity", "connectivity_regions"], - "dependencies": ["identity-foundations", "governance-folders"], + "inputs": [ + "connectivity", + "connectivity_regions" + ], + "dependencies": [ + "identity-foundations", + "governance-folders" + ], "sources": [ "src/modules/connectivity/variables.tf", "src/modules/connectivity/1-network-area.tf", @@ -46,7 +57,12 @@ "externalSources": [ "https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/network_area" ], - "scenarios": ["A04", "A05", "A15", "A24"] + "scenarios": [ + "A04", + "A05", + "A15", + "A24" + ] }, { "id": "identity-foundations", @@ -54,7 +70,9 @@ "version": 3, "checkedOn": "2026-10-12", "qualification": "local-draft-not-reviewed-release", - "groups": ["identity"], + "groups": [ + "identity" + ], "mandatory": false, "inputs": [ "organization_id", @@ -65,10 +83,20 @@ "labels", "platform_contract_namespace" ], - "dependencies": ["credential-foundations"], - "sources": ["src/variables.tf", "src/providers.tf", "src/main.tf"], + "dependencies": [ + "credential-foundations" + ], + "sources": [ + "src/variables.tf", + "src/providers.tf", + "src/main.tf" + ], "externalSources": [], - "scenarios": ["A01", "A21", "A23"] + "scenarios": [ + "A01", + "A21", + "A23" + ] }, { "id": "governance-folders", @@ -76,7 +104,9 @@ "version": 3, "checkedOn": "2026-10-12", "qualification": "local-draft-not-reviewed-release", - "groups": ["governance"], + "groups": [ + "governance" + ], "mandatory": false, "inputs": [ "rm_folder_parent_id", @@ -84,7 +114,9 @@ "organization_owners", "organization_auditors" ], - "dependencies": ["identity-foundations"], + "dependencies": [ + "identity-foundations" + ], "sources": [ "src/modules/governance/variables.tf", "src/modules/governance/1-rm-folders.tf", @@ -93,18 +125,27 @@ "landing-zone-configurator/app/packages/domain/src/common-validation.ts" ], "externalSources": [], - "scenarios": ["A02", "A23"] + "scenarios": [ + "A02", + "A23" + ] }, { "id": "management-state", "file": "management-state.md", - "version": 1, - "checkedOn": "2026-10-10", + "version": 2, + "checkedOn": "2026-10-21", "qualification": "local-draft-not-reviewed-release", - "groups": ["management"], + "groups": [ + "management" + ], "mandatory": false, - "inputs": [], - "dependencies": ["governance-folders"], + "inputs": [ + "bucket_naming" + ], + "dependencies": [ + "governance-folders" + ], "sources": [ "src/main.tf", "src/modules/management/variables.tf", @@ -113,30 +154,54 @@ "src/modules/management/3-object-storage.tf", "src/modules/management/4-service-account.tf", "landing-zone-configurator/docs/common-feature-model.md", - "landing-zone-configurator/docs/deployment-credentials.md" + "landing-zone-configurator/docs/deployment-credentials.md", + "landing-zone-configurator/app/apps/api/src/deployments/preparations.ts", + "landing-zone-configurator/app/packages/domain/src/initial-plan.ts" ], "externalSources": [], - "scenarios": ["A03", "A16", "A23"] + "scenarios": [ + "A03", + "A16", + "A23" + ] }, { "id": "observability", "file": "observability.md", - "version": 1, - "checkedOn": "2026-10-10", + "version": 2, + "checkedOn": "2026-10-21", "qualification": "local-draft-not-reviewed-release", - "groups": ["management"], + "groups": [ + "management" + ], "mandatory": false, - "inputs": ["observability"], - "dependencies": ["management-state"], + "inputs": [ + "observability" + ], + "dependencies": [ + "management-state" + ], "sources": [ "src/variables.tf", "src/modules/management/variables.tf", "src/modules/management/5-observability.tf", + "landing-zone-configurator/app/packages/domain/src/observability-plans.ts", + "landing-zone-configurator/app/apps/api/src/credentials/catalogues.ts", "src/modules/landing-zone/variables.tf", - "src/modules/platform-kubernetes/variables.tf" + "src/modules/platform-kubernetes/variables.tf", + "landing-zone-configurator/app/apps/api/src/assistant/service.ts", + "landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts" ], - "externalSources": [], - "scenarios": ["A03", "A09", "A10", "A24"] + "externalSources": [ + "https://docs.stackit.cloud/products/logging-and-monitoring/observability/reference/service-plans-observability/", + "https://github.com/stackitcloud/stackit-sdk-go/blob/main/services/observability/v1api/model_plan.go" + ], + "scenarios": [ + "A03", + "A09", + "A10", + "A24" + ] }, { "id": "audit-logs", @@ -144,10 +209,16 @@ "version": 1, "checkedOn": "2026-10-10", "qualification": "local-draft-not-reviewed-release", - "groups": ["management"], + "groups": [ + "management" + ], "mandatory": false, - "inputs": ["audit_logs"], - "dependencies": ["management-state"], + "inputs": [ + "audit_logs" + ], + "dependencies": [ + "management-state" + ], "sources": [ "src/variables.tf", "src/main.tf", @@ -155,7 +226,12 @@ "src/modules/management/6-audit-logs.tf" ], "externalSources": [], - "scenarios": ["A03", "A16", "A23", "A24"] + "scenarios": [ + "A03", + "A16", + "A23", + "A24" + ] }, { "id": "federated-identities", @@ -163,10 +239,16 @@ "version": 1, "checkedOn": "2026-10-10", "qualification": "local-draft-not-reviewed-release", - "groups": ["management"], + "groups": [ + "management" + ], "mandatory": false, - "inputs": ["federated_identity_providers"], - "dependencies": ["management-state"], + "inputs": [ + "federated_identity_providers" + ], + "dependencies": [ + "management-state" + ], "sources": [ "src/variables.tf", "src/main.tf", @@ -174,7 +256,11 @@ "src/modules/management/4-service-account.tf" ], "externalSources": [], - "scenarios": ["A03", "A16", "A23"] + "scenarios": [ + "A03", + "A16", + "A23" + ] }, { "id": "dns-zones", @@ -182,10 +268,17 @@ "version": 6, "checkedOn": "2026-10-16", "qualification": "local-draft-not-reviewed-release", - "groups": ["network"], + "groups": [ + "network" + ], "mandatory": false, - "inputs": ["connectivity", "connectivity_regions"], - "dependencies": ["stackit-network-components"], + "inputs": [ + "connectivity", + "connectivity_regions" + ], + "dependencies": [ + "stackit-network-components" + ], "sources": [ "src/modules/connectivity/variables.tf", "src/modules/connectivity/6-dns-zones.tf", @@ -198,7 +291,14 @@ "https://docs.stackit.cloud/products/network/core-networking/dns/how-tos/manage-dns-zones/", "https://raw.githubusercontent.com/stackitcloud/terraform-provider-stackit/v0.114.0/stackit/internal/services/dns/zone/resource.go" ], - "scenarios": ["A04", "A05", "A09", "A12", "A15", "A24"] + "scenarios": [ + "A04", + "A05", + "A09", + "A12", + "A15", + "A24" + ] }, { "id": "firewall-infrastructure", @@ -206,10 +306,18 @@ "version": 5, "checkedOn": "2026-10-13", "qualification": "local-draft-not-reviewed-release", - "groups": ["network", "firewall"], + "groups": [ + "network", + "firewall" + ], "mandatory": false, - "inputs": ["connectivity", "connectivity_regions"], - "dependencies": ["stackit-network-components"], + "inputs": [ + "connectivity", + "connectivity_regions" + ], + "dependencies": [ + "stackit-network-components" + ], "sources": [ "src/modules/connectivity/variables.tf", "src/modules/connectivity/5-firewall.tf", @@ -220,7 +328,12 @@ "src/main.tf" ], "externalSources": [], - "scenarios": ["A04", "A06", "A15", "A24"] + "scenarios": [ + "A04", + "A06", + "A15", + "A24" + ] }, { "id": "vpn", @@ -228,10 +341,18 @@ "version": 1, "checkedOn": "2026-10-10", "qualification": "local-draft-not-reviewed-release", - "groups": ["network"], + "groups": [ + "network" + ], "mandatory": false, - "inputs": ["connectivity", "connectivity_regions", "vpn_pre_shared_keys"], - "dependencies": ["stackit-network-components"], + "inputs": [ + "connectivity", + "connectivity_regions", + "vpn_pre_shared_keys" + ], + "dependencies": [ + "stackit-network-components" + ], "sources": [ "src/variables.tf", "src/main.tf", @@ -240,7 +361,11 @@ "landing-zone-configurator/docs/vpn-configuration-scope.md" ], "externalSources": [], - "scenarios": ["A07", "A16", "A24"] + "scenarios": [ + "A07", + "A16", + "A24" + ] }, { "id": "firewall-policy", @@ -248,10 +373,17 @@ "version": 1, "checkedOn": "2026-10-10", "qualification": "local-draft-not-reviewed-release", - "groups": ["firewall"], + "groups": [ + "firewall" + ], "mandatory": false, - "inputs": ["firewall_config"], - "dependencies": ["firewall-infrastructure", "firewall-access-bootstrap"], + "inputs": [ + "firewall_config" + ], + "dependencies": [ + "firewall-infrastructure", + "firewall-access-bootstrap" + ], "sources": [ "src/variables.tf", "src/modules/firewall-config/variables.tf", @@ -260,7 +392,12 @@ "src/_firewall-bootstrap.tf" ], "externalSources": [], - "scenarios": ["A13", "A16", "A23", "A24"] + "scenarios": [ + "A13", + "A16", + "A23", + "A24" + ] }, { "id": "firewall-access-bootstrap", @@ -268,7 +405,10 @@ "version": 1, "checkedOn": "2026-10-10", "qualification": "local-draft-not-reviewed-release", - "groups": ["firewall", "credentials"], + "groups": [ + "firewall", + "credentials" + ], "mandatory": false, "inputs": [ "firewall_admin_username", @@ -277,7 +417,10 @@ "firewall_bootstrap", "firewall_api_secret_version" ], - "dependencies": ["firewall-infrastructure", "management-state"], + "dependencies": [ + "firewall-infrastructure", + "management-state" + ], "sources": [ "src/variables.tf", "src/_firewall-bootstrap.tf", @@ -285,7 +428,11 @@ "landing-zone-configurator/docs/deployment-credentials.md" ], "externalSources": [], - "scenarios": ["A13", "A16", "A23"] + "scenarios": [ + "A13", + "A16", + "A23" + ] }, { "id": "devops-git", @@ -293,10 +440,16 @@ "version": 1, "checkedOn": "2026-10-10", "qualification": "local-draft-not-reviewed-release", - "groups": ["platform"], + "groups": [ + "platform" + ], "mandatory": false, - "inputs": ["devops"], - "dependencies": ["management-state"], + "inputs": [ + "devops" + ], + "dependencies": [ + "management-state" + ], "sources": [ "src/variables.tf", "src/modules/devops/variables.tf", @@ -304,7 +457,10 @@ "src/providers.tf" ], "externalSources": [], - "scenarios": ["A08", "A24"] + "scenarios": [ + "A08", + "A24" + ] }, { "id": "platform-kubernetes", @@ -312,13 +468,18 @@ "version": 1, "checkedOn": "2026-10-10", "qualification": "local-draft-not-reviewed-release", - "groups": ["platform"], + "groups": [ + "platform" + ], "mandatory": false, "inputs": [ "platform_kubernetes", "platform_kubernetes_kube_config_override" ], - "dependencies": ["stackit-network-components", "observability"], + "dependencies": [ + "stackit-network-components", + "observability" + ], "sources": [ "src/variables.tf", "src/modules/platform-kubernetes/variables.tf", @@ -328,7 +489,13 @@ "landing-zone-configurator/app/packages/domain/src/common-validation.ts" ], "externalSources": [], - "scenarios": ["A05", "A09", "A15", "A16", "A24"] + "scenarios": [ + "A05", + "A09", + "A15", + "A16", + "A24" + ] }, { "id": "project-templates", @@ -336,9 +503,13 @@ "version": 1, "checkedOn": "2026-10-10", "qualification": "local-draft-not-reviewed-release", - "groups": ["projects"], + "groups": [ + "projects" + ], "mandatory": false, - "inputs": ["landing_zones"], + "inputs": [ + "landing_zones" + ], "dependencies": [ "governance-folders", "stackit-network-components", @@ -353,7 +524,14 @@ "landing-zone-configurator/app/packages/domain/src/common-validation.ts" ], "externalSources": [], - "scenarios": ["A04", "A10", "A14", "A22", "A23", "A24"] + "scenarios": [ + "A04", + "A10", + "A14", + "A22", + "A23", + "A24" + ] }, { "id": "sandbox-projects", @@ -361,10 +539,16 @@ "version": 1, "checkedOn": "2026-10-10", "qualification": "local-draft-not-reviewed-release", - "groups": ["projects"], + "groups": [ + "projects" + ], "mandatory": false, - "inputs": ["sandboxes"], - "dependencies": ["governance-folders"], + "inputs": [ + "sandboxes" + ], + "dependencies": [ + "governance-folders" + ], "sources": [ "src/variables.tf", "src/modules/sandboxes/variables.tf", @@ -372,7 +556,11 @@ "landing-zone-configurator/app/packages/domain/src/common-document.ts" ], "externalSources": [], - "scenarios": ["A11", "A14", "A23"] + "scenarios": [ + "A11", + "A14", + "A23" + ] }, { "id": "namespace-services", @@ -380,10 +568,17 @@ "version": 1, "checkedOn": "2026-10-10", "qualification": "local-draft-not-reviewed-release", - "groups": ["projects"], + "groups": [ + "projects" + ], "mandatory": false, - "inputs": ["landing_zone_namespace_services"], - "dependencies": ["platform-kubernetes", "project-templates"], + "inputs": [ + "landing_zone_namespace_services" + ], + "dependencies": [ + "platform-kubernetes", + "project-templates" + ], "sources": [ "src/variables.tf", "src/_landing-zone-kubernetes.tf", @@ -392,7 +587,13 @@ "landing-zone-configurator/app/packages/domain/src/common-validation.ts" ], "externalSources": [], - "scenarios": ["A12", "A14", "A16", "A23", "A24"] + "scenarios": [ + "A12", + "A14", + "A16", + "A23", + "A24" + ] }, { "id": "configuration-lifecycle", @@ -403,7 +604,9 @@ "groups": [], "mandatory": true, "inputs": [], - "dependencies": ["credential-foundations"], + "dependencies": [ + "credential-foundations" + ], "sources": [ "landing-zone-configurator/docs/common-feature-model.md", "landing-zone-configurator/app/packages/domain/src/common-document.ts", diff --git a/landing-zone-configurator/docs/assistant-knowledge/observability.de.md b/landing-zone-configurator/docs/assistant-knowledge/observability.de.md index e0008032..0943cbca 100644 --- a/landing-zone-configurator/docs/assistant-knowledge/observability.de.md +++ b/landing-zone-configurator/docs/assistant-knowledge/observability.de.md @@ -47,10 +47,20 @@ Unterstuetzung. ## Beispiele `metrics_retention_days=30`: Diese Wahl und Plan/ACL des Nutzers vorschlagen. -Andere Aufbewahrungsueberschreibungen auslassen, um native Defaults zu erben; +Andere Aufbewahrungsueberschreibungen auslassen, um den Servicestandard zu erben; keine Beispielwerte fuer Logs/Traces kopieren oder bereits beantwortete ACLs erneut abfragen. Optionale Aufbewahrungswerte sind keine Pflichtentscheidungen. +Alle zentralen Aufbewahrungsfelder akzeptieren `null` oder Auslassen für den +Servicestandard. Frische Live-Kapazitäten haben je Signal Vorrang innerhalb des +geprüften nativen Vertrags; fehlende Daten nutzen dokumentierte statische Regeln. +Das bestätigt keine aktuelle Verfügbarkeit. Monitoring unterstützt nur Metriken; +Frontend/MetricsEndpoint erlauben keine Aufbewahrungsüberschreibungen. Unbekannte +Unterstützung ist keine Freigabe. Ganzzahlige Tagesgrenzen: Logs/Traces 1–30, +Metriken/Downsampling 1–780. Editor, Vorbereitung und Vorschlagsübernahme verwenden +dieselbe Prüfung. Gespeicherte Werte und Tarif bleiben bis zur bewussten Korrektur +erhalten. + ## Grenzen diff --git a/landing-zone-configurator/docs/assistant-knowledge/observability.md b/landing-zone-configurator/docs/assistant-knowledge/observability.md index f84b7c9d..05514401 100644 --- a/landing-zone-configurator/docs/assistant-knowledge/observability.md +++ b/landing-zone-configurator/docs/assistant-knowledge/observability.md @@ -2,45 +2,49 @@ ## Purpose -Logs, traces and metrics centrally or per project; scope and retention follow -operating needs, not template labels. +Choose signals, scope, retention and dashboard access for operating needs. ## Accelerator Contract observability=null disables central management monitoring. Project and -platform_kubernetes observability have separate nested contracts. Central fields: -plan_name, acl, logs/traces/metrics and downsampling retention periods. A project -instance does not imply the same central service or authorization. +platform_kubernetes have separate nested observability contracts. Central fields: +plan_name, acl and logs/traces/metrics/downsampling retention. Project monitoring +implies neither central monitoring nor its authorization. ## Dependencies -Management, credentials, project/cluster, ingestion paths and deliberate ACLs. -Respect nested scope. Ordinary telemetry does not prove audit logging. +Management, credentials, project/cluster, ingestion and deliberate ACLs. +Telemetry does not prove audit logging. ## Dialogue -Clarify signals/projects, retention needs, access and dashboard operation. -Separate central/project scope. Reuse live plans/catalogues; a native default -plan name does not prove availability, quotas, prices or cross-region support. +Clarify signals/projects, retention and access. Reuse live plan catalogues; +native defaults prove no availability, quota, price or cross-region support. ## Examples -metrics_retention_days=30: propose that choice and the user's plan/ACL. Omit other -retention overrides to inherit native defaults; do not copy sample logs/traces -values or ask for answered ACLs. Optional retentions are not mandatory decisions. +For metrics_retention_days=30, propose that value and the user's plan/ACL. +Omit other retention overrides; do not copy sample values or repeat answered ACLs. +All central retention fields accept null/omission for service defaults. +Fresh live capacities override static families per signal; missing data uses the +reviewed fallback, not an availability claim. Monitoring has metrics only; +Frontend/MetricsEndpoint have no configurable retention. Unknown support is not +permission. Whole-day limits: logs/traces 1-30, metrics/downsampling 1-780. +Never change plans or discard saved values silently. Optional overrides are not +mandatory decisions. Editor, preparation and proposal acceptance share validation. ## Limits -Estimates need measured volume and current limits. Empty/default ACLs are not -automatically private or secure. Demos do not prove production coverage. -Typed native Editor proposals change only the local draft; execution is separate. +Estimates need measured volume and current limits. Default ACLs are not +inherently private. Demos prove no production coverage. Typed Editor proposals +change only the local draft; execution is separate. ## Credentials -Separate provisioning from ingestion/dashboard credentials. No Grafana -passwords, service tokens, OTLP secrets or raw customer logs in chat. +Separate provisioning from ingestion/dashboard access. No passwords, tokens, +OTLP secrets or customer logs in chat. ## Sources -Management/project/platform variables and manifest/engine binding. Refresh after -review. Live product facts and operational qualification remain separate. \ No newline at end of file +Native variables and manifest/engine binding; refresh after review. +Live product facts and operational qualification remain separate. diff --git a/landing-zone-configurator/docs/assistant-test-catalogue.json b/landing-zone-configurator/docs/assistant-test-catalogue.json index 9b964389..97528762 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": "0fd2c183ebdf903b2e7d10e50dac5e4b3abd08e9f665fcc15bbab01b95669299", + "knowledgeSha256": "436435106841a476ec0c7cf7a948f0f94e9ac99566ebfdb98838980fe45f3738", "sourceDigests": { - "scripts/assistant-scenarios.ts": "441273d9af541a14185062728a9f9a7044df0e8a7c51645a53eb970a341867d9", + "scripts/assistant-scenarios.ts": "d9d361ddc0da9d928abeedfa85e6da189f3fed25aa581ca7264723ac85ac24a9", "scripts/assistant-tests.ts": "5e19b1c74da1ffa996f2d76876cc8dc639da698d0b6f9b7e1fc6637863833a8d" }, - "suiteSha256": "8c60fd19e7e5a6e8903ccea7987f834112c948b4de8cb60487a1694bc64fe2ee", + "suiteSha256": "a186d02cfe1f3c0aa0b82d058006dbb1029b2e4b07020e0e934db478354f19b9", "topics": [ { "id": "credential-foundations", @@ -823,8 +823,33 @@ }, { "id": "management-state", - "fields": [], + "fields": [ + { + "path": "bucket_naming", + "group": "management", + "type": "string", + "optional": true, + "proposalPath": "bucket_naming", + "responsibility": "user", + "mode": "propose-validated-draft" + } + ], "cases": [ + { + "id": "editor-project-unique-buckets", + "topic": "management-state", + "fixture": "baseline", + "message": "Bitte Bucket-Namensschema im lokalen Entwurf ausdrücklich auf bucket_naming=project-id setzen, damit die vollständige jeweilige Projekt-ID enthalten ist. Keine sonstigen Änderungen, kein Plan oder Apply.", + "editorExpected": { + "path": "bucket_naming", + "value": "project-id" + }, + "assertions": [ + "valid_dialogue", + "reviewed_topic_reference", + "no_changes" + ] + }, { "id": "management-state-advice", "topic": "management-state", @@ -939,6 +964,21 @@ "no_changes" ] }, + { + "id": "editor-monitoring-retention", + "topic": "observability", + "fixture": "baseline", + "message": "Bitte zentrale Observability als lokalen Entwurf einrichten: plan_name Observability-Monitoring-Medium-EU01, ACL 203.0.113.0/24, metrics_retention_days 30. Logs und Traces sind im Monitoring-Tarif nicht unterstützt, dafür keine Retention setzen. Keine Ausführung.", + "editorExpected": { + "path": "observability.metrics_retention_days", + "value": 30 + }, + "assertions": [ + "valid_dialogue", + "reviewed_topic_reference", + "no_changes" + ] + }, { "id": "observability-advice", "topic": "observability", diff --git a/landing-zone-configurator/docs/common-editor.de.md b/landing-zone-configurator/docs/common-editor.de.md index 9d4fef98..8eacc4c1 100644 --- a/landing-zone-configurator/docs/common-editor.de.md +++ b/landing-zone-configurator/docs/common-editor.de.md @@ -493,3 +493,31 @@ Pflichtangaben und Tunnel-Einstellungen prüfen. VPN-Ausführung bleibt gesperrt Der anschließend entstandene separate Application-Root und sein Compiler sind ein lokaler bzw. im Feature-Branch prüfbarer Prototyp, kein Bestandteil der aktivierten Self-Service-Oberfläche. + + +## Fähigkeiten von Observability-Tarifen + +Der Editor behält Tarifauswahl und alle expliziten Aufbewahrungswerte bei. Frische +Katalogeinträge tragen ausschließlich validierte Kapazitätsmerkmale für +Metriken/Logs/Traces, keine rohen API-Antworten. Kapazität null verbietet die +entsprechende Aufbewahrungsüberschreibung; fehlende Angaben gelten als unbekannt. +Daten älter als 15 Minuten oder aus der Zukunft gelten nicht als live. Fehlende +Felder verwenden die geprüfte statische Tarifgruppenregel. Live-Daten dürfen den +gepinnten nativen Vertrag weiter einschränken, aber keine Einstellungen freigeben, +die der geprüfte native Runner ausdrücklich zurückweist. + +Monitoring unterstützt Metrikaufbewahrung, keine Logs/Traces. Frontend und Metrics +Endpoint haben keine konfigurierbare Aufbewahrung. Unbekannte Unterstützung wird +zur Prüfung angezeigt; Vorbereitung weist nicht verifizierte explizite Optionen +zurück. Alle zentralen Aufbewahrungsfelder akzeptieren Auslassen/null für den +Servicestandard. Ganzzahlige Tagesgrenzen entsprechen den dokumentierten +Servicegrenzen: Logs/Traces 1–30, Metriken/Downsampling 1–780. Dies garantiert weder +Quoten noch Verfügbarkeit im Zielprojekt. + +Bekannt unzulässige optionale Felder lassen sich nicht aktivieren. Vorhandene +inkompatible Werte bleiben sichtbar und können bewusst zurückgesetzt werden; +ein Tarifwechsel entfernt sie niemals. Die Live-Prüfung erhält andere Prüfungen, +einschließlich ACLs. Der Server prüft Fähigkeiten vor Vorbereitung und +Assistant-Vorschlagsübernahme erneut. Der Assistant erhält dieselben sicheren +Merkmale und geprüften Fallback-Fakten. Weder Katalogzugriff noch +Vorschlagsübernahme erlauben Apply. diff --git a/landing-zone-configurator/docs/common-editor.md b/landing-zone-configurator/docs/common-editor.md index a1b9fe04..d07491c5 100644 --- a/landing-zone-configurator/docs/common-editor.md +++ b/landing-zone-configurator/docs/common-editor.md @@ -481,3 +481,28 @@ required inputs and tunnel settings. VPN execution remains blocked. The subsequently created separate Application root and its compiler are a prototype testable locally or on the feature branch, not part of the activated self-service interface. + + +## Observability plan capabilities + +The editor retains the selected plan and all explicit retention values. Fresh +Observability catalogue entries carry only validated metric/log/trace capacity +flags, not raw API responses. A zero capacity forbids its retention override; +missing capacity is unknown. Data older than 15 minutes or from the future is +not treated as live. Missing fields fall back to the reviewed service-family +policy. Live data can further restrict the pinned native contract, but cannot +expand settings that the reviewed native engine explicitly rejects. + +Monitoring supports metrics retention, not logs or traces. Frontend and Metrics +Endpoint have no configurable retention. Unknown support is shown for review; +preparation rejects unverified explicit options. All central retention fields +accept omission/null for service defaults. Whole-day bounds use the documented +service limits: logs/traces 1–30, metrics/downsampling 1–780. This is not a quota +or target-project availability guarantee. + +Known unsupported optional fields cannot be activated. Existing incompatible +values stay visible and can be deliberately reset; selecting another plan never +drops them. Live-aware validation preserves unrelated validation, including ACLs. +The server rechecks capabilities before preparation and Assistant proposal +acceptance. The Assistant receives the same safe capability flags and reviewed +fallback facts. Neither catalogue access nor proposal acceptance authorizes Apply. diff --git a/landing-zone-configurator/docs/deployment-credentials.de.md b/landing-zone-configurator/docs/deployment-credentials.de.md index f9d81657..aa7a271f 100644 --- a/landing-zone-configurator/docs/deployment-credentials.de.md +++ b/landing-zone-configurator/docs/deployment-credentials.de.md @@ -23,6 +23,15 @@ Stand: 2026-09-30. Nächster Baustein nach Fork-Speicherung und tfvars-Export. ## Optionaler Administrationszugang für Firewall-HA +Verwende den aktuellen Appliance-Login, kein gewünschtes künftiges Passwort. Ein +frisch gestartetes gepinntes Image behält seinen Image-Zugang, bis ein Betreiber +ihn ändert; das Speichern des Profils legt weder Benutzer an noch konfiguriert es +eine VM. Prüfe beide WAN-Login-Seiten im Connectivity-Projekt. Hat ein Apply die +VMs bereits erstellt, das Profil korrigieren und eine neue Vorbereitung/einen +neuen Plan mit dem erhaltenen Checkpoint verwenden; weder VMs neu erstellen noch +den verbrauchten gespeicherten Plan erneut ausführen. + + Aktiviere beim Anlegen des Profils **OPNsense-Administrationszugang für Firewall-HA hinterlegen** und trage den bestehenden GUI-Administrator samt Passwort für beide Appliances ein. Einzelne Firewall-Instanzen benötigen diesen zusätzlichen Zugang diff --git a/landing-zone-configurator/docs/deployment-credentials.md b/landing-zone-configurator/docs/deployment-credentials.md index 71e9a2f5..2e43746a 100644 --- a/landing-zone-configurator/docs/deployment-credentials.md +++ b/landing-zone-configurator/docs/deployment-credentials.md @@ -19,6 +19,14 @@ As of 2026-09-30. Next building block after fork storage and tfvars export. ## Optional Firewall HA administration +Use the current appliance login, not a desired future password. A freshly booted +pinned image retains its image account until an operator changes it; storing the +profile does not create a user or configure either VM. Check both WAN login pages +in the connectivity project. If an Apply already created the VMs, correct the +profile and use a new preparation/Plan against the retained checkpoint; do not +recreate the VMs or reuse the consumed saved Plan. + + For Firewall HA, enable **Store OPNsense administration credentials for Firewall HA** when creating the profile and enter the existing GUI administrator username and password for both appliances. Single-node Firewall infrastructure does not diff --git a/landing-zone-configurator/docs/deployment-preparation.de.md b/landing-zone-configurator/docs/deployment-preparation.de.md index 59a7e6ce..e003c37a 100644 --- a/landing-zone-configurator/docs/deployment-preparation.de.md +++ b/landing-zone-configurator/docs/deployment-preparation.de.md @@ -105,7 +105,7 @@ künftigen Plan/Apply müssen Zugriff, Secret-Version und Ziel erneut geprüft w Ergänzung vom 2026-10-06: Die gewöhnliche Vorbereitung bleibt auf `a256f68` gebunden. Für den Contract-Output kann eine zusätzliche Vorbereitung ausdrücklich -den fest qualifizierten Commit `c4b43c36af198985980b17626c48d357795e3fbd` wählen. +den fest qualifizierten Commit `9e7fb46c329aca32e878ce1f0ea3b944ea240354` wählen. Das ist derzeit ein lokaler, opt-in Laufpfad, keine allgemeine Source-Auswahl und keine Produktionsfreigabe. @@ -123,16 +123,26 @@ Revision und das eigene Credential-Profil. Die zusätzliche Auswahl lautet: { "platformUpgrade": { "confirm": true, - "acceleratorCommit": "c4b43c36af198985980b17626c48d357795e3fbd" + "acceleratorCommit": "9e7fb46c329aca32e878ce1f0ea3b944ea240354" } } ``` -Der Server verlangt ein passendes qualifiziertes Paket sowie einen bestehenden, -entsperrten S3-State. Fremde Revisionen, zusätzliche Namespace-/Tenant-Felder und -abweichende Backend-Bindungen werden abgelehnt. Die neue Vorbereitung verwendet -denselben State und dieselbe Konfiguration. Alte Manifeste, Artefakte, Revisionen -und Freigaben werden nicht umgeschrieben. +Der Server verlangt ein passendes qualifiziertes Paket und behält die vorhandene +State-Bindung bei. Eine initialisierte Plattform benötigt normalerweise einen +entsperrten S3-State. Nach einem teilweise fehlgeschlagenen Bootstrap-Apply kann +stattdessen der vorhandene verschlüsselte Checkpoint verwendet werden, wenn der +Eigentümer ihn über „Checkpoint prüfen“ ausdrücklich geprüft und zur Wiederaufnahme +freigegeben hat. Der Nachweis muss exakt zur aktuellen State-Version, Konfiguration +und zum Mandanten passen. Der fehlgeschlagene Apply muss den aktuell festgelegten +Plattform-Quellstand oder dessen unterstützten Vorgänger +`c4b43c36af198985980b17626c48d357795e3fbd` verwendet haben. +Sperren, offene Migrationen, separater Recovery-State und aktive Läufe sperren diesen +Pfad; ein geänderter Checkpoint benötigt eine erneute Prüfung. Es wird weder ein +leerer State angelegt noch das Backend migriert. Fremde Revisionen, zusätzliche +Namespace-/Tenant-Felder und abweichende Backend-Bindungen werden abgelehnt. Die +neue Vorbereitung verwendet denselben State und dieselbe Konfiguration. Alte +Manifeste, Artefakte, Revisionen und Freigaben werden nicht umgeschrieben. Der Contract-Namensraum stammt aus der authentifizierten Serversitzung und ist Teil des exakten neuen tfvars-Hashes. Die API lockert ihre Tenant-/Organisationsprüfung diff --git a/landing-zone-configurator/docs/deployment-preparation.md b/landing-zone-configurator/docs/deployment-preparation.md index 9799a931..6177edcc 100644 --- a/landing-zone-configurator/docs/deployment-preparation.md +++ b/landing-zone-configurator/docs/deployment-preparation.md @@ -100,7 +100,7 @@ future Plan/Apply, access, secret version and target must be checked again. Addition from 2026-10-06: ordinary preparation remains bound to `a256f68`. For contract output, an additional preparation can explicitly -select the fixed qualified commit `c4b43c36af198985980b17626c48d357795e3fbd`. +select the fixed qualified commit `9e7fb46c329aca32e878ce1f0ea3b944ea240354`. This is currently a local opt-in execution path, not general source selection or production approval. @@ -118,16 +118,23 @@ revision and own credential profile. The additional selection is: { "platformUpgrade": { "confirm": true, - "acceleratorCommit": "c4b43c36af198985980b17626c48d357795e3fbd" + "acceleratorCommit": "9e7fb46c329aca32e878ce1f0ea3b944ea240354" } } ``` -The server requires a matching qualified package and an existing, -unlocked S3 state. Other revisions, extra namespace/tenant fields and -different backend bindings are rejected. The new preparation uses -the same state and configuration. Old manifests, artifacts, revisions -and approvals are not rewritten. +The server requires a matching qualified package and preserves the existing state +binding. An initialized platform normally requires an unlocked S3 state. A +partially failed Bootstrap Apply can instead use its existing encrypted checkpoint +only after the owner explicitly reviews and reconciles it through “Check checkpoint”. +The reconciliation receipt must match the exact current state version, configuration +and tenant. The failed Apply must have used the current pinned platform source or +its supported predecessor `c4b43c36af198985980b17626c48d357795e3fbd`. +Locks, pending migrations, separate recovery state and active runs block this path; +a changed checkpoint requires renewed review. This does not create an empty state +or migrate the backend. Other revisions, extra namespace/tenant fields and different +backend bindings are rejected. The new preparation uses the same state and +configuration. Old manifests, artifacts, revisions and approvals are not rewritten. The contract namespace comes from the authenticated server session and is part of the exact new tfvars hash. The API does not relax its tenant/organization check. diff --git a/landing-zone-configurator/docs/plan-execution.de.md b/landing-zone-configurator/docs/plan-execution.de.md index da180e19..8899ac8c 100644 --- a/landing-zone-configurator/docs/plan-execution.de.md +++ b/landing-zone-configurator/docs/plan-execution.de.md @@ -339,6 +339,45 @@ States bleiben gesperrt und benoetigen einen gesonderten operativen Abgleich. Es gibt keine automatische Entsperrung, State-Loeschung, Import- oder Destroy-Aktion. Die Migration muss vor Nutzung der neuen API aktiviert sein. +### Weltweit eindeutige Bucket-Namen + +Object-Storage-Bucket-Namen gelten STACKIT-weit. `bucket_naming = "project-id"` +ist der Standard für Management- und Workload-Buckets. Namen enthalten die +vollständige ID ihres Projekts und ein Zwecksuffix (`default`, `tfstate`, bei +Bedarf `audit-logs`). Nur der lesbare Präfix wird auf insgesamt höchstens 63 Zeichen +gekürzt. + +Vor dem Upgrade einer Installation mit bereits angelegten bisherigen Bucket-Namen +`bucket_naming = "legacy"` explizit im Root bzw. Kindmodul setzen. Andernfalls plant +OpenTofu Ersetzungen; diese Änderung migriert weder Bucket-Inhalte noch die +Backend-Konfiguration. Vorhandenen State beibehalten und den vollständigen Plan +prüfen. Ein teilweise ausgeführter Bootstrap ohne Bucket-Instanzen kann mit seiner +bestehenden Projekt-ID den neuen Standard verwenden. Configurator-Ausführung +benötigt zusätzlich einen geprüften Runner-Pin mit diesem nativen Vertrag; eine +Quellcodeänderung allein aktualisiert keinen gehosteten Runner. +Siehe [den erfassten Namensfehler](https://github.com/stackitcloud/stackit-landing-zone/issues/127). + +### Mehrere Fehler bei einem teilweise ausgeführten Bootstrap-Apply + +Unabhängige Zweige können fehlschlagen, nachdem andere Ressourcen bereits erstellt +wurden. Konfigurationsidentität und Bootstrap-Checkpoint beibehalten. Vor der +Freigabe eines neuen Plans den Checkpoint prüfen; weder den verbrauchten Plan +wiederholen noch eine neue leere Bereitstellung bestätigen. Nach den Korrekturen +den neuen Plan auf unerwartete Ersetzungen oder Löschungen prüfen und Apply +separat freigeben. + +Ein Monitoring-Serviceplan unterstützt keine Logs-/Trace-Aufbewahrung. Diese +Felder auslassen oder explizit auf null setzen; Root und Management-Modul erhalten +null und weisen explizite Monitoring-Werte vor Apply zurück. Andere Tarife nutzen +bei ausgelassenen Werten ihren Servicestandard; explizite Werte bleiben erhalten. + +Ein Object-Storage-Fehler `bucket.name_conflict` belegt keine Zugehörigkeit des +Buckets zu dieser Bereitstellung. Vor Import oder gezielter Namenskorrektur den +Checkpoint mit einer autorisierten Abfrage im exakten Management-Projekt +abgleichen. Dafür nicht `company_code` ändern: Das betrifft viele bestehende +Ressourcen. Ein sensibler HA-Provisioner kann seine Fehlerausgabe verbergen; +erstellte VMs belegen weder eine erfolgreiche Anmeldung noch die HA-Konfiguration. + ### Erfolgreichen Apply mit offener S3-Bestaetigung abschliessen diff --git a/landing-zone-configurator/docs/plan-execution.md b/landing-zone-configurator/docs/plan-execution.md index 7a387d02..298d55a7 100644 --- a/landing-zone-configurator/docs/plan-execution.md +++ b/landing-zone-configurator/docs/plan-execution.md @@ -126,6 +126,42 @@ Migration `034_platform_reconciliation.sql` adds explicit, audited UI reconcilia The user can then save the corrected configuration, choose a new preparation and start a new Plan against the same existing state. Apply again requires separate review and approval of the new saved plan. The old plan remains consumed. If the checkpoint has changed meanwhile, a new check is required. Ambiguous Runner aborts, unverifiable migrations and separate recovery states remain blocked and need separate operational reconciliation. No automatic unblocking, state deletion, import or Destroy action. The migration must be enabled before using the new API. +### Globally unique bucket names + +Object Storage bucket names are STACKIT-wide. `bucket_naming = "project-id"` is +the default for management and workload buckets. Names include the complete owning +project UUID and a purpose suffix (`default`, `tfstate`, `audit-logs` where used). +Only the readable prefix is truncated to keep names within 63 characters. + +Before upgrading an installation that already owns legacy-named buckets, explicitly +set `bucket_naming = "legacy"` in the root (or child module). Otherwise OpenTofu +will plan replacements; this change does not migrate bucket contents or backend +configuration. Keep existing state and review the full Plan. A partial bootstrap +with no bucket instances can use the new default with its existing project ID. +Configurator execution additionally requires a reviewed Runner pin containing +this native contract; a source change alone does not update a hosted Runner. +See [the tracked naming defect](https://github.com/stackitcloud/stackit-landing-zone/issues/127). + +### Multiple errors during a partial bootstrap Apply + +Independent branches can fail after other resources were created. Keep the same +configuration identity and bootstrap checkpoint. Check the checkpoint before +allowing replanning; do not repeat the consumed saved plan or confirm a new empty +deployment. After corrections, inspect the new plan for unexpected replacement +or deletion before separately approving Apply. + +A Monitoring service plan cannot accept logs/traces retention. Omit these fields +or explicitly set them to null; the root and management module preserve null and +reject explicit Monitoring overrides before Apply. Other plans use their service +defaults when these values are omitted; existing explicit retention is preserved. + +An Object Storage `bucket.name_conflict` is not proof that a bucket belongs to +this deployment. Compare the checkpoint with an authorized lookup in the exact +management project before considering import or a targeted naming correction. +Do not change `company_code` to resolve it: that affects many existing resources. +A sensitive HA provisioner can hide its error output; resource creation alone +proves neither appliance authentication nor completed HA configuration. + ### Completing a successful Apply with pending S3 confirmation diff --git a/src/README.de.md b/src/README.de.md index 9135b8ab..b335dcad 100644 --- a/src/README.de.md +++ b/src/README.de.md @@ -76,6 +76,7 @@ maßgeblich ist die [kanonische englische Referenz](README.de.md). | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | | [audit\_logs](#input\_audit\_logs) | Audit log routing for the management module. A Telemetry Link streams audit events into a Telemetry Router, which forwards them to a Logs instance and archives them to object storage. link\_scopes defaults to a single organization-wide link. retention\_days applies to both the Logs instance and, when s3\_object\_lock is enabled, the archive bucket's default retention. s3\_object\_lock is off by default and turns on GOVERNANCE-mode WORM protection for the archive bucket, which is not cleanly reversible. Set to null to skip audit logs deployment. |
object({
retention_days = optional(number, 180)
acl = optional(list(string), ["0.0.0.0/0"])
s3_object_lock = optional(bool, true)
link_scopes = optional(list(object({
resource_type = string # organization, folder, project
resource_id = string
})), null)
})
| `null` | no | +| [bucket\_naming](#input\_bucket\_naming) | Object Storage bucket naming scheme: legacy preserves existing names; project-id includes the complete owning project UUID for STACKIT-wide uniqueness. Changing this for existing buckets requires a reviewed migration. | `string` | `"project-id"` | no | | [company\_code](#input\_company\_code) | Company code used in resource naming conventions. | `string` | n/a | yes | | [company\_name](#input\_company\_name) | Name of the company. | `string` | n/a | yes | | [connectivity](#input\_connectivity) | Connectivity configuration including DNS zones, network areas, firewalls, and VPN. Use network\_areas with descriptive keys that reflect any business, security, tenant, or regional boundary; network\_area and firewall remain supported for existing single-area configurations. Use firewalls for per-area appliance settings. |
object({
naming_pattern = optional(string, null)
dns_zones = optional(map(object({
dns_name = string
network_area_key = optional(string, "default")
region = optional(string, null)
name = optional(string, null)
contact_email = optional(string, null)
type = optional(string, "primary")
acl = optional(string, null)
description = optional(string, null)
default_ttl = optional(number, 3600)
})), {})
network_area = optional(object({
ranges = list(string)
transfer_network = string
min_prefix_length = optional(number, 24)
max_prefix_length = optional(number, 28)
default_prefix_length = optional(number, 28)
# Unset means the STACKIT resolvers of var.region, see modules/connectivity/1-network-area.tf.
default_nameservers = optional(list(string), null)
}), null)
network_areas = optional(map(object({
name = optional(string, null)
ranges = list(string)
transfer_network = string
min_prefix_length = optional(number, 24)
max_prefix_length = optional(number, 28)
default_prefix_length = optional(number, 28)
default_nameservers = optional(list(string), null)
})), null)
firewall = optional(object({
zone = string
flavor = string
name = string
volume_performance_class = optional(string, "storage_premium_perf4")
volume_size = optional(number, 16)
lan_network_range = string
wan_network_range = string
lan_ip = optional(string, null)
wan_ip = optional(string, null)

# Active/passive CARP pair. Adds a second appliance in backup_zone and a CARP
# virtual IP on the LAN that replaces the primary's LAN IP as the next hop the
# landing zone routes point at, so a node failure never touches the routes.
# See docs/architecture.md#high-availability-optional.
ha = optional(object({
backup_zone = string
backup_name = optional(string, null) # defaults to "-backup"
backup_lan_ip = optional(string, null) # defaults to the 6th address of lan_network_range
backup_wan_ip = optional(string, null) # defaults to the 6th address of wan_network_range
lan_vip = optional(string, null) # defaults to the 7th address of lan_network_range
vhid = optional(number, 1)
}), null)
}), null)
firewalls = optional(map(object({
zone = string
flavor = string
name = string
volume_performance_class = optional(string, "storage_premium_perf4")
volume_size = optional(number, 16)
lan_network_range = string
wan_network_range = string
lan_ip = optional(string, null)
wan_ip = optional(string, null)
ha = optional(object({
backup_zone = string
backup_name = optional(string, null)
backup_lan_ip = optional(string, null)
backup_wan_ip = optional(string, null)
lan_vip = optional(string, null)
vhid = optional(number, 1)
}), null)
})), null)
vpn = optional(object({
display_name = optional(string, null)
plan_id = optional(string, "p100")
routing_type = optional(string, "ROUTE_BASED")
availability_zones = object({
tunnel1 = string
tunnel2 = string
})
connections = optional(map(object({
display_name = optional(string, null)
enabled = optional(bool, true)
local_subnets = optional(list(string), null)
remote_subnets = optional(list(string), null)
static_routes = optional(list(string), null)
tunnel1 = object({
remote_address = string
peering = optional(object({
local_address = string
remote_address = string
}), null)
phase1 = optional(object({
encryption_algorithms = optional(list(string), ["aes256"])
integrity_algorithms = optional(list(string), ["sha2_384"])
dh_groups = optional(list(string), ["ecp384"])
rekey_time = optional(number, null)
}), {})
phase2 = optional(object({
encryption_algorithms = optional(list(string), ["aes256"])
integrity_algorithms = optional(list(string), ["sha2_384"])
dh_groups = optional(list(string), ["ecp384"])
rekey_time = optional(number, null)
dpd_action = optional(string, null)
start_action = optional(string, null)
}), {})
})
tunnel2 = object({
remote_address = string
peering = optional(object({
local_address = string
remote_address = string
}), null)
phase1 = optional(object({
encryption_algorithms = optional(list(string), ["aes256"])
integrity_algorithms = optional(list(string), ["sha2_384"])
dh_groups = optional(list(string), ["ecp384"])
rekey_time = optional(number, null)
}), {})
phase2 = optional(object({
encryption_algorithms = optional(list(string), ["aes256"])
integrity_algorithms = optional(list(string), ["sha2_384"])
dh_groups = optional(list(string), ["ecp384"])
rekey_time = optional(number, null)
dpd_action = optional(string, null)
start_action = optional(string, null)
}), {})
})
})), {})
}), null)
})
| `null` | no | @@ -91,7 +92,7 @@ maßgeblich ist die [kanonische englische Referenz](README.de.md). | [labels](#input\_labels) | Additional labels to apply to all resources. | `map(string)` | `{}` | no | | [landing\_zone\_namespace\_services](#input\_landing\_zone\_namespace\_services) | Map of namespace-service configurations per landing zone key. If a key is present, namespace service is enabled for that landing zone. |
map(object({
demo_enabled = optional(bool, false)
demo_metrics_ingestion = optional(object({
enabled = optional(bool, false)
target_urls = optional(list(string), [])
scheme = optional(string, "https")
metrics_path = optional(string, "/")
scrape_interval = optional(string, "70s")
scrape_timeout = optional(string, "30s")
}), {})
namespace = optional(string, null)
dns_subdomain = optional(string, null)
secretsmanager = optional(bool, true)
sample_load = optional(object({
enabled = optional(bool, false)
image = optional(string, "busybox:1.36")
}), {})
secrets_enforcement = optional(object({
enabled = optional(bool, false)
mode = optional(string, "audit")
allow_opaque_secret_types = optional(list(string), [])
break_glass = optional(object({
enabled = optional(bool, true)
ttl_hours = optional(number, 24)
principals = optional(list(string), [])
}), {})
}), {})
kubernetes_access = optional(object({
enabled = optional(bool, true)
service_account_name = optional(string, null)
}), {})
labels = optional(map(string), {})
annotations = optional(map(string), {})
}))
| `{}` | no | | [landing\_zones](#input\_landing\_zones) | Map of landing zones to create. Public landing zones can create a local network with network\_enabled. Corporate landing zones use network\_area\_key to select a connectivity.network\_areas entry; set region to eu01 or eu02 with connectivity\_regions. |
map(object({
project_name = string
project_code = string
owner_email = string
# Set to true for corporate landing zones (connected to network area), false for public
corporate = optional(bool, true)
network_area_key = optional(string, "default")
region = optional(string, null)
env = optional(string, "dev")
role_assignments = optional(list(object({
role = string
subject = string
})), [])
network_enabled = optional(bool, false)
network_prefix_length = optional(number, null)
secretsmanager_enabled = optional(bool, true)
custom_roles = optional(list(object({
name = string
description = string
permissions = list(string)
})), [])
observability = optional(object({
enabled = optional(bool, false)
plan_name = optional(string, "Observability-Starter-EU01")
acl = optional(list(string), [])
name = optional(string, null)
}), {})
}))
| `{}` | no | -| [observability](#input\_observability) | Observability instance configuration for the management module. Set to null to skip observability deployment. |
object({
plan_name = optional(string, "Observability-Starter-EU01")
acl = optional(list(string), [])
logs_retention_days = optional(number, 30)
traces_retention_days = optional(number, 30)
metrics_retention_days = optional(number, 90)
metrics_retention_days_5m_downsampling = optional(number, 90)
metrics_retention_days_1h_downsampling = optional(number, 90)
})
| `null` | no | +| [observability](#input\_observability) | Observability instance configuration for the management module. Omitted or null retention uses the service plan defaults. Monitoring plans require both retention fields to be omitted or null. Set to null to skip observability deployment. |
object({
plan_name = optional(string, "Observability-Starter-EU01")
acl = optional(list(string), [])
logs_retention_days = optional(number)
traces_retention_days = optional(number)
metrics_retention_days = optional(number)
metrics_retention_days_5m_downsampling = optional(number)
metrics_retention_days_1h_downsampling = optional(number)
})
| `null` | no | | [organization\_auditors](#input\_organization\_auditors) | List of organization auditors. | `list(string)` | `[]` | no | | [organization\_id](#input\_organization\_id) | Container ID of the root organization. | `string` | n/a | yes | | [organization\_owners](#input\_organization\_owners) | List of organization owners. | `list(string)` | `[]` | no | diff --git a/src/README.md b/src/README.md index 90ac98db..11634546 100644 --- a/src/README.md +++ b/src/README.md @@ -73,6 +73,7 @@ The generated technical reference below documents this root module. | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | | [audit\_logs](#input\_audit\_logs) | Audit log routing for the management module. A Telemetry Link streams audit events into a Telemetry Router, which forwards them to a Logs instance and archives them to object storage. link\_scopes defaults to a single organization-wide link. retention\_days applies to both the Logs instance and, when s3\_object\_lock is enabled, the archive bucket's default retention. s3\_object\_lock is off by default and turns on GOVERNANCE-mode WORM protection for the archive bucket, which is not cleanly reversible. Set to null to skip audit logs deployment. |
object({
retention_days = optional(number, 180)
acl = optional(list(string), ["0.0.0.0/0"])
s3_object_lock = optional(bool, true)
link_scopes = optional(list(object({
resource_type = string # organization, folder, project
resource_id = string
})), null)
})
| `null` | no | +| [bucket\_naming](#input\_bucket\_naming) | Object Storage bucket naming scheme: legacy preserves existing names; project-id includes the complete owning project UUID for STACKIT-wide uniqueness. Changing this for existing buckets requires a reviewed migration. | `string` | `"project-id"` | no | | [company\_code](#input\_company\_code) | Company code used in resource naming conventions. | `string` | n/a | yes | | [company\_name](#input\_company\_name) | Name of the company. | `string` | n/a | yes | | [connectivity](#input\_connectivity) | Connectivity configuration including DNS zones, network areas, firewalls, and VPN. Use network\_areas with descriptive keys that reflect any business, security, tenant, or regional boundary; network\_area and firewall remain supported for existing single-area configurations. Use firewalls for per-area appliance settings. |
object({
naming_pattern = optional(string, null)
dns_zones = optional(map(object({
dns_name = string
network_area_key = optional(string, "default")
region = optional(string, null)
name = optional(string, null)
contact_email = optional(string, null)
type = optional(string, "primary")
acl = optional(string, null)
description = optional(string, null)
default_ttl = optional(number, 3600)
})), {})
network_area = optional(object({
ranges = list(string)
transfer_network = string
min_prefix_length = optional(number, 24)
max_prefix_length = optional(number, 28)
default_prefix_length = optional(number, 28)
# Unset means the STACKIT resolvers of var.region, see modules/connectivity/1-network-area.tf.
default_nameservers = optional(list(string), null)
}), null)
network_areas = optional(map(object({
name = optional(string, null)
ranges = list(string)
transfer_network = string
min_prefix_length = optional(number, 24)
max_prefix_length = optional(number, 28)
default_prefix_length = optional(number, 28)
default_nameservers = optional(list(string), null)
})), null)
firewall = optional(object({
zone = string
flavor = string
name = string
volume_performance_class = optional(string, "storage_premium_perf4")
volume_size = optional(number, 16)
lan_network_range = string
wan_network_range = string
lan_ip = optional(string, null)
wan_ip = optional(string, null)

# Active/passive CARP pair. Adds a second appliance in backup_zone and a CARP
# virtual IP on the LAN that replaces the primary's LAN IP as the next hop the
# landing zone routes point at, so a node failure never touches the routes.
# See docs/architecture.md#high-availability-optional.
ha = optional(object({
backup_zone = string
backup_name = optional(string, null) # defaults to "-backup"
backup_lan_ip = optional(string, null) # defaults to the 6th address of lan_network_range
backup_wan_ip = optional(string, null) # defaults to the 6th address of wan_network_range
lan_vip = optional(string, null) # defaults to the 7th address of lan_network_range
vhid = optional(number, 1)
}), null)
}), null)
firewalls = optional(map(object({
zone = string
flavor = string
name = string
volume_performance_class = optional(string, "storage_premium_perf4")
volume_size = optional(number, 16)
lan_network_range = string
wan_network_range = string
lan_ip = optional(string, null)
wan_ip = optional(string, null)
ha = optional(object({
backup_zone = string
backup_name = optional(string, null)
backup_lan_ip = optional(string, null)
backup_wan_ip = optional(string, null)
lan_vip = optional(string, null)
vhid = optional(number, 1)
}), null)
})), null)
vpn = optional(object({
display_name = optional(string, null)
plan_id = optional(string, "p100")
routing_type = optional(string, "ROUTE_BASED")
availability_zones = object({
tunnel1 = string
tunnel2 = string
})
connections = optional(map(object({
display_name = optional(string, null)
enabled = optional(bool, true)
local_subnets = optional(list(string), null)
remote_subnets = optional(list(string), null)
static_routes = optional(list(string), null)
tunnel1 = object({
remote_address = string
peering = optional(object({
local_address = string
remote_address = string
}), null)
phase1 = optional(object({
encryption_algorithms = optional(list(string), ["aes256"])
integrity_algorithms = optional(list(string), ["sha2_384"])
dh_groups = optional(list(string), ["ecp384"])
rekey_time = optional(number, null)
}), {})
phase2 = optional(object({
encryption_algorithms = optional(list(string), ["aes256"])
integrity_algorithms = optional(list(string), ["sha2_384"])
dh_groups = optional(list(string), ["ecp384"])
rekey_time = optional(number, null)
dpd_action = optional(string, null)
start_action = optional(string, null)
}), {})
})
tunnel2 = object({
remote_address = string
peering = optional(object({
local_address = string
remote_address = string
}), null)
phase1 = optional(object({
encryption_algorithms = optional(list(string), ["aes256"])
integrity_algorithms = optional(list(string), ["sha2_384"])
dh_groups = optional(list(string), ["ecp384"])
rekey_time = optional(number, null)
}), {})
phase2 = optional(object({
encryption_algorithms = optional(list(string), ["aes256"])
integrity_algorithms = optional(list(string), ["sha2_384"])
dh_groups = optional(list(string), ["ecp384"])
rekey_time = optional(number, null)
dpd_action = optional(string, null)
start_action = optional(string, null)
}), {})
})
})), {})
}), null)
})
| `null` | no | @@ -88,7 +89,7 @@ The generated technical reference below documents this root module. | [labels](#input\_labels) | Additional labels to apply to all resources. | `map(string)` | `{}` | no | | [landing\_zone\_namespace\_services](#input\_landing\_zone\_namespace\_services) | Map of namespace-service configurations per landing zone key. If a key is present, namespace service is enabled for that landing zone. |
map(object({
demo_enabled = optional(bool, false)
demo_metrics_ingestion = optional(object({
enabled = optional(bool, false)
target_urls = optional(list(string), [])
scheme = optional(string, "https")
metrics_path = optional(string, "/")
scrape_interval = optional(string, "70s")
scrape_timeout = optional(string, "30s")
}), {})
namespace = optional(string, null)
dns_subdomain = optional(string, null)
secretsmanager = optional(bool, true)
sample_load = optional(object({
enabled = optional(bool, false)
image = optional(string, "busybox:1.36")
}), {})
secrets_enforcement = optional(object({
enabled = optional(bool, false)
mode = optional(string, "audit")
allow_opaque_secret_types = optional(list(string), [])
break_glass = optional(object({
enabled = optional(bool, true)
ttl_hours = optional(number, 24)
principals = optional(list(string), [])
}), {})
}), {})
kubernetes_access = optional(object({
enabled = optional(bool, true)
service_account_name = optional(string, null)
}), {})
labels = optional(map(string), {})
annotations = optional(map(string), {})
}))
| `{}` | no | | [landing\_zones](#input\_landing\_zones) | Map of landing zones to create. Public landing zones can create a local network with network\_enabled. Corporate landing zones use network\_area\_key to select a connectivity.network\_areas entry; set region to eu01 or eu02 with connectivity\_regions. |
map(object({
project_name = string
project_code = string
owner_email = string
# Set to true for corporate landing zones (connected to network area), false for public
corporate = optional(bool, true)
network_area_key = optional(string, "default")
region = optional(string, null)
env = optional(string, "dev")
role_assignments = optional(list(object({
role = string
subject = string
})), [])
network_enabled = optional(bool, false)
network_prefix_length = optional(number, null)
secretsmanager_enabled = optional(bool, true)
custom_roles = optional(list(object({
name = string
description = string
permissions = list(string)
})), [])
observability = optional(object({
enabled = optional(bool, false)
plan_name = optional(string, "Observability-Starter-EU01")
acl = optional(list(string), [])
name = optional(string, null)
}), {})
}))
| `{}` | no | -| [observability](#input\_observability) | Observability instance configuration for the management module. Set to null to skip observability deployment. |
object({
plan_name = optional(string, "Observability-Starter-EU01")
acl = optional(list(string), [])
logs_retention_days = optional(number, 30)
traces_retention_days = optional(number, 30)
metrics_retention_days = optional(number, 90)
metrics_retention_days_5m_downsampling = optional(number, 90)
metrics_retention_days_1h_downsampling = optional(number, 90)
})
| `null` | no | +| [observability](#input\_observability) | Observability instance configuration for the management module. Omitted or null retention uses the service plan defaults. Monitoring plans require both retention fields to be omitted or null. Set to null to skip observability deployment. |
object({
plan_name = optional(string, "Observability-Starter-EU01")
acl = optional(list(string), [])
logs_retention_days = optional(number)
traces_retention_days = optional(number)
metrics_retention_days = optional(number)
metrics_retention_days_5m_downsampling = optional(number)
metrics_retention_days_1h_downsampling = optional(number)
})
| `null` | no | | [organization\_auditors](#input\_organization\_auditors) | List of organization auditors. | `list(string)` | `[]` | no | | [organization\_id](#input\_organization\_id) | Container ID of the root organization. | `string` | n/a | yes | | [organization\_owners](#input\_organization\_owners) | List of organization owners. | `list(string)` | `[]` | no | diff --git a/src/application/.terraform.lock.hcl b/src/application/.terraform.lock.hcl index c65b4c72..e334a297 100644 --- a/src/application/.terraform.lock.hcl +++ b/src/application/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.opentofu.org/hashicorp/time" { version = "0.14.1" constraints = ">= 0.14.1, 0.14.1" hashes = [ + "h1:Ssb164oIHIO9VWGljof/xqbxbnPmrT5jyJ+WegAj2+k=", "h1:jfH6FAhiYd3hKB29s8cxk9PUKGdEN0fPB3zQ0IE5pdQ=", "zh:032ea0f53759a5ade64286ad8a403956bb390860429de3647c6652701c2fff8b", "zh:204581f170c50a579357b1a067f407b890adfc0404952cb922fefe2aba7655a8", @@ -29,6 +30,7 @@ provider "registry.opentofu.org/stackitcloud/stackit" { constraints = ">= 0.114.0, 0.114.0" hashes = [ "h1:1LWQQnyh7olj5+Ceu33qfZrtvwV6oB4OatOT01QhHBQ=", + "h1:Pw89rX8OcLNY06pMMsdZ48ATRHRdc14Hqr7Gz70jfRo=", "zh:096d6a1d716f124e9a7576318ffb2128ae0ac29e7c1f13b1b3c7723b79f760de", "zh:0dde99e7b343fa01f8eefc378171fb8621bedb20f59157d6cc8e3d46c738105f", "zh:1d48af034c791b5565a86c4905abd309152bf50c2c67c44e4943a750a3b0b060", diff --git a/src/application/README.de.md b/src/application/README.de.md index 98d60ad6..0c711072 100644 --- a/src/application/README.de.md +++ b/src/application/README.de.md @@ -147,3 +147,46 @@ Noch abzunehmen: echter, separat autorisierter zweiphasiger CLI-Plan/Apply, Backend-Sperrung/-Wiederherstellung, minimal berechtigte Zugangsdaten, ausdrückliche Migration bestehender States und vollständiger Configurator-Dispatch. Mock-Tests sind keine Cloud-Abnahme. + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.11 | +| [stackit](#requirement\_stackit) | 0.114.0 | +| [time](#requirement\_time) | 0.14.1 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [terraform](#provider\_terraform) | n/a | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [application](#module\_application) | ../modules/landing-zone | n/a | + +## Resources + +| Name | Type | +| ---- | ---- | +| [terraform_data.contract](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [application](#input\_application) | Resolved application instance supplied manually or by the Configurator. Identity and deployment authorization are enforced by the caller's IAM permissions. |
object({
configurator_execution = optional(bool, false)
tenant_id = string
instance_id = string
platform_revision = string
template_id = string
template_version = number
name = string
env = optional(string, null)
owner_email = string
target_key = string
network_enabled = optional(bool, false)
network_prefix_length = optional(number, null)
secretsmanager_enabled = bool
custom_roles = optional(list(object({
name = string
description = string
permissions = list(string)
})), [])
role_assignments = optional(list(object({
role = string
subject = string
})), [])
observability = object({
enabled = bool
plan_name = string
acl = list(string)
access_source = optional(string, "explicit-cidrs")
})
})
| n/a | yes | +| [bucket\_naming](#input\_bucket\_naming) | Object Storage bucket naming scheme: legacy preserves existing names; project-id includes the complete owning project UUID for STACKIT-wide uniqueness. Changing this for existing buckets requires a reviewed migration. | `string` | `"project-id"` | no | +| [platform\_contract](#input\_platform\_contract) | Reviewed non-secret platform references supplied as a CLI artifact or by the Configurator. No remote-state access. |
object({
schema_version = number
tenant_id = string
revision = string
organization_id = string
targets = map(object({
folder_id = string
region = string
corporate = bool
network_area_id = optional(string, null)
firewall_next_hop_ip = optional(string, null)
ipv4_nameservers = optional(list(string), null)
}))
})
| n/a | yes | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [application\_resources](#output\_application\_resources) | Non-secret resource references; never publish service credentials or complete state. | + diff --git a/src/application/README.md b/src/application/README.md index 5c606f39..d0f2b5cf 100644 --- a/src/application/README.md +++ b/src/application/README.md @@ -134,3 +134,45 @@ addition to the credential-free native mock tests. Remaining acceptance: real separately authorized two-phase CLI Plan/Apply, backend lock/recovery, least-privilege credentials, explicit existing-state migration and completed Configurator dispatch. Mock tests are not cloud acceptance. + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.11 | +| [stackit](#requirement\_stackit) | 0.114.0 | +| [time](#requirement\_time) | 0.14.1 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [terraform](#provider\_terraform) | n/a | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [application](#module\_application) | ../modules/landing-zone | n/a | + +## Resources + +| Name | Type | +| ---- | ---- | +| [terraform_data.contract](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [application](#input\_application) | Resolved application instance supplied manually or by the Configurator. Identity and deployment authorization are enforced by the caller's IAM permissions. |
object({
configurator_execution = optional(bool, false)
tenant_id = string
instance_id = string
platform_revision = string
template_id = string
template_version = number
name = string
env = optional(string, null)
owner_email = string
target_key = string
network_enabled = optional(bool, false)
network_prefix_length = optional(number, null)
secretsmanager_enabled = bool
custom_roles = optional(list(object({
name = string
description = string
permissions = list(string)
})), [])
role_assignments = optional(list(object({
role = string
subject = string
})), [])
observability = object({
enabled = bool
plan_name = string
acl = list(string)
access_source = optional(string, "explicit-cidrs")
})
})
| n/a | yes | +| [bucket\_naming](#input\_bucket\_naming) | Object Storage bucket naming scheme: legacy preserves existing names; project-id includes the complete owning project UUID for STACKIT-wide uniqueness. Changing this for existing buckets requires a reviewed migration. | `string` | `"project-id"` | no | +| [platform\_contract](#input\_platform\_contract) | Reviewed non-secret platform references supplied as a CLI artifact or by the Configurator. No remote-state access. |
object({
schema_version = number
tenant_id = string
revision = string
organization_id = string
targets = map(object({
folder_id = string
region = string
corporate = bool
network_area_id = optional(string, null)
firewall_next_hop_ip = optional(string, null)
ipv4_nameservers = optional(list(string), null)
}))
})
| n/a | yes | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [application\_resources](#output\_application\_resources) | Non-secret resource references; never publish service credentials or complete state. | + \ No newline at end of file diff --git a/src/application/main.tf b/src/application/main.tf index 50c9a9d5..b8030cad 100644 --- a/src/application/main.tf +++ b/src/application/main.tf @@ -45,6 +45,7 @@ module "application" { organization_id = var.platform_contract.organization_id parent_container_id = try(local.target.folder_id, "") naming_pattern = local.naming_pattern + bucket_naming = var.bucket_naming project_name = var.application.name owner_email = var.application.owner_email custom_roles = var.application.custom_roles diff --git a/src/application/variables.tf b/src/application/variables.tf index 021df85b..8c7ef479 100644 --- a/src/application/variables.tf +++ b/src/application/variables.tf @@ -1,3 +1,14 @@ +variable "bucket_naming" { + type = string + description = "Object Storage bucket naming scheme: legacy preserves existing names; project-id includes the complete owning project UUID for STACKIT-wide uniqueness. Changing this for existing buckets requires a reviewed migration." + default = "project-id" + + validation { + condition = contains(["legacy", "project-id"], var.bucket_naming) + error_message = "Bucket naming must be legacy or project-id." + } +} + variable "application" { type = object({ configurator_execution = optional(bool, false) diff --git a/src/main.tf b/src/main.tf index d7563940..54e91a8c 100644 --- a/src/main.tf +++ b/src/main.tf @@ -28,6 +28,7 @@ module "management" { organization_id = var.organization_id labels = local.resource_labels observability = var.observability + bucket_naming = var.bucket_naming audit_logs = var.audit_logs federated_identity_providers = var.federated_identity_providers } @@ -273,6 +274,7 @@ module "landing_zone" { organization_id = var.organization_id parent_container_id = each.value.corporate ? module.governance.folder_container_ids["landing_zones_corporate"] : module.governance.folder_container_ids["landing_zones_public"] naming_pattern = "${var.company_code}-lz-${each.value.project_code}-${each.value.env}" + bucket_naming = var.bucket_naming dns_zone_name = try("${each.value.project_code}-${each.value.env}-${var.region}-${split(".", values(module.connectivity[0].dns_zone_dns_names)[0])[0]}.stackit.run", null) network_area_id = each.value.corporate ? try(module.connectivity[0].network_area_id[each.value.network_area_key], null) : null corporate = each.value.corporate @@ -296,6 +298,7 @@ module "landing_zone_eu01" { organization_id = var.organization_id parent_container_id = each.value.corporate ? module.governance.folder_container_ids["landing_zones_corporate"] : module.governance.folder_container_ids["landing_zones_public"] naming_pattern = "${var.company_code}-lz-${each.value.project_code}-${each.value.env}" + bucket_naming = var.bucket_naming dns_zone_name = try("${each.value.project_code}-${each.value.env}-eu01-${split(".", values(module.connectivity_eu01[0].dns_zone_dns_names)[0])[0]}.stackit.run", null) network_area_id = each.value.corporate ? try(module.connectivity_eu01[0].network_area_id[each.value.network_area_key], null) : null corporate = each.value.corporate @@ -319,6 +322,7 @@ module "landing_zone_eu02" { organization_id = var.organization_id parent_container_id = each.value.corporate ? module.governance.folder_container_ids["landing_zones_corporate"] : module.governance.folder_container_ids["landing_zones_public"] naming_pattern = "${var.company_code}-lz-${each.value.project_code}-${each.value.env}" + bucket_naming = var.bucket_naming dns_zone_name = try("${each.value.project_code}-${each.value.env}-eu02-${split(".", values(module.connectivity_eu02[0].dns_zone_dns_names)[0])[0]}.stackit.run", null) network_area_id = each.value.corporate ? try(module.connectivity_eu02[0].network_area_id[each.value.network_area_key], null) : null corporate = each.value.corporate diff --git a/src/modules/landing-zone/5-bucket.tf b/src/modules/landing-zone/5-bucket.tf index eee8c3db..7d3512a0 100644 --- a/src/modules/landing-zone/5-bucket.tf +++ b/src/modules/landing-zone/5-bucket.tf @@ -1,14 +1,25 @@ +locals { + # Preserve the complete project UUID and purpose within S3's 63-character limit. + bucket_names = { + for purpose in ["default", "tfstate"] : purpose => ( + var.bucket_naming == "project-id" + ? "${substr(var.naming_pattern, 0, 63 - length(purpose) - 38)}-${stackit_resourcemanager_project.this.project_id}-${purpose}" + : "${var.naming_pattern}-${purpose}" + ) + } +} + #################### ## OBJECT STORAGE ## #################### resource "stackit_objectstorage_bucket" "default" { - name = "${var.naming_pattern}-default" + name = local.bucket_names["default"] project_id = stackit_resourcemanager_project.this.project_id } resource "stackit_objectstorage_bucket" "tfstate" { - name = "${var.naming_pattern}-tfstate" + name = local.bucket_names["tfstate"] project_id = stackit_resourcemanager_project.this.project_id depends_on = [ diff --git a/src/modules/landing-zone/README.de.md b/src/modules/landing-zone/README.de.md index bbf24620..2c246dea 100644 --- a/src/modules/landing-zone/README.de.md +++ b/src/modules/landing-zone/README.de.md @@ -23,8 +23,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 @@ -56,6 +56,7 @@ No modules. | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | +| [bucket\_naming](#input\_bucket\_naming) | Object Storage bucket naming scheme: legacy preserves existing names; project-id includes the complete owning project UUID for STACKIT-wide uniqueness. Changing this for existing buckets requires a reviewed migration. | `string` | `"project-id"` | no | | [corporate](#input\_corporate) | Whether this landing zone uses corporate networking (network area + routing). Set to false for public internet. | `bool` | `false` | no | | [custom\_roles](#input\_custom\_roles) | List of custom roles to create for the project. |
list(object({
name = string
description = string
permissions = list(string)
}))
| n/a | yes | | [dns\_zone\_name](#input\_dns\_zone\_name) | Full DNS zone domain name for this landing zone. Set to null to skip DNS zone creation. | `string` | `null` | no | @@ -92,5 +93,6 @@ No modules. | [project\_id](#output\_project\_id) | The project ID of the created STACKIT project. | | [project\_name](#output\_project\_name) | The name of the created STACKIT project. | | [project\_network](#output\_project\_network) | Non-secret reference to the managed project network, or null when no network is created. | +| [project\_role\_counts](#output\_project\_role\_counts) | Counts of custom roles and explicit role assignments managed by this project module. | | [secretsmanager\_instance\_id](#output\_secretsmanager\_instance\_id) | The ID of the landing zone Secrets Manager instance. | diff --git a/src/modules/landing-zone/README.md b/src/modules/landing-zone/README.md index 3aa100f8..9e086410 100644 --- a/src/modules/landing-zone/README.md +++ b/src/modules/landing-zone/README.md @@ -20,8 +20,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 @@ -53,6 +53,7 @@ No modules. | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | +| [bucket\_naming](#input\_bucket\_naming) | Object Storage bucket naming scheme: legacy preserves existing names; project-id includes the complete owning project UUID for STACKIT-wide uniqueness. Changing this for existing buckets requires a reviewed migration. | `string` | `"project-id"` | no | | [corporate](#input\_corporate) | Whether this landing zone uses corporate networking (network area + routing). Set to false for public internet. | `bool` | `false` | no | | [custom\_roles](#input\_custom\_roles) | List of custom roles to create for the project. |
list(object({
name = string
description = string
permissions = list(string)
}))
| n/a | yes | | [dns\_zone\_name](#input\_dns\_zone\_name) | Full DNS zone domain name for this landing zone. Set to null to skip DNS zone creation. | `string` | `null` | no | @@ -89,5 +90,6 @@ No modules. | [project\_id](#output\_project\_id) | The project ID of the created STACKIT project. | | [project\_name](#output\_project\_name) | The name of the created STACKIT project. | | [project\_network](#output\_project\_network) | Non-secret reference to the managed project network, or null when no network is created. | +| [project\_role\_counts](#output\_project\_role\_counts) | Counts of custom roles and explicit role assignments managed by this project module. | | [secretsmanager\_instance\_id](#output\_secretsmanager\_instance\_id) | The ID of the landing zone Secrets Manager instance. | diff --git a/src/modules/landing-zone/tests/network.tftest.hcl b/src/modules/landing-zone/tests/network.tftest.hcl index 49dac10f..07cc3bb2 100644 --- a/src/modules/landing-zone/tests/network.tftest.hcl +++ b/src/modules/landing-zone/tests/network.tftest.hcl @@ -111,6 +111,7 @@ run "objectstorage_credentials_group_name_bounded" { variables { naming_pattern = "app-116aee67-9384-4b50-a683-f392b6367e0d-dev" + bucket_naming = "legacy" } assert { @@ -135,4 +136,32 @@ run "objectstorage_credentials_group_name_distinct" { condition = length(stackit_objectstorage_credentials_group.this.name) == 32 && stackit_objectstorage_credentials_group.this.name != "${substr("app-116aee67-9384-4b50-a683-f392b6367e0d-dev", 0, 23)}-${substr(sha256("app-116aee67-9384-4b50-a683-f392b6367e0d-dev"), 0, 8)}" error_message = "Credentials group names with a shared truncated prefix must remain distinct." } -} \ No newline at end of file +} +run "project_qualified_bucket_defaults" { + command = plan + + variables { + naming_pattern = "a-very-long-but-valid-application-resource-name-prefix" + } + + assert { + condition = alltrue([ + for name in [stackit_objectstorage_bucket.default.name, stackit_objectstorage_bucket.tfstate.name] : + length(name) <= 63 && strcontains(name, stackit_resourcemanager_project.this.project_id) + ]) && stackit_objectstorage_bucket.default.name != stackit_objectstorage_bucket.tfstate.name + error_message = "Workload buckets must include their full owning project UUID with distinct purposes within 63 characters." + } +} + +run "explicit_legacy_bucket_compatibility" { + command = plan + + variables { + bucket_naming = "legacy" + } + + assert { + condition = stackit_objectstorage_bucket.default.name == "application-dev-default" && stackit_objectstorage_bucket.tfstate.name == "application-dev-tfstate" + error_message = "Explicit legacy mode must preserve existing workload bucket names." + } +} diff --git a/src/modules/landing-zone/variables.tf b/src/modules/landing-zone/variables.tf index fc9679c0..a8bb2dd1 100644 --- a/src/modules/landing-zone/variables.tf +++ b/src/modules/landing-zone/variables.tf @@ -1,3 +1,14 @@ +variable "bucket_naming" { + type = string + description = "Object Storage bucket naming scheme: legacy preserves existing names; project-id includes the complete owning project UUID for STACKIT-wide uniqueness. Changing this for existing buckets requires a reviewed migration." + default = "project-id" + + validation { + condition = contains(["legacy", "project-id"], var.bucket_naming) + error_message = "Bucket naming must be legacy or project-id." + } +} + variable "organization_id" { type = string description = "Container ID of the root organization." diff --git a/src/modules/management/3-object-storage.tf b/src/modules/management/3-object-storage.tf index f5a7c7d7..7606dfb3 100644 --- a/src/modules/management/3-object-storage.tf +++ b/src/modules/management/3-object-storage.tf @@ -1,14 +1,25 @@ +locals { + # Preserve the complete project UUID and purpose within S3's 63-character limit. + bucket_names = { + for purpose in ["default", "tfstate", "audit-logs"] : purpose => ( + var.bucket_naming == "project-id" + ? "${substr(var.naming_pattern, 0, 63 - length(purpose) - 38)}-${stackit_resourcemanager_project.this.project_id}-${purpose}" + : "${var.naming_pattern}-${purpose}" + ) + } +} + ############# ## BUCKETS ## ############# resource "stackit_objectstorage_bucket" "default" { - name = "${var.naming_pattern}-default" + name = local.bucket_names["default"] project_id = stackit_resourcemanager_project.this.project_id } resource "stackit_objectstorage_bucket" "tfstate" { - name = "${var.naming_pattern}-tfstate" + name = local.bucket_names["tfstate"] project_id = stackit_resourcemanager_project.this.project_id depends_on = [ @@ -28,7 +39,7 @@ resource "stackit_objectstorage_compliance_lock" "this" { } resource "stackit_objectstorage_bucket" "audit_logs" { - name = "${var.naming_pattern}-audit-logs" + name = local.bucket_names["audit-logs"] project_id = stackit_resourcemanager_project.this.project_id object_lock = try(var.audit_logs.s3_object_lock, false) ? true : null diff --git a/src/modules/management/README.de.md b/src/modules/management/README.de.md index 21c23ad0..e8317810 100644 --- a/src/modules/management/README.de.md +++ b/src/modules/management/README.de.md @@ -68,10 +68,11 @@ No modules. | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | | [audit\_logs](#input\_audit\_logs) | Audit logs configuration. The router forwards to two destinations: OTLP into the Logs instance for querying, and S3 into the audit bucket for long-term archive. retention\_days applies to both, driving the Logs instance retention and, when s3\_object\_lock is enabled, the archive bucket's default retention. link\_scopes defaults to a single organization-wide link; set it to attach individual folders or projects instead. |
object({
retention_days = optional(number, 180)
acl = optional(list(string), null)
s3_object_lock = optional(bool, true)
link_scopes = optional(list(object({
resource_type = string # organization, folder, project
resource_id = string
})), null)
})
| `null` | no | +| [bucket\_naming](#input\_bucket\_naming) | Management bucket naming scheme: legacy preserves existing names; project-id includes the complete management project UUID for STACKIT-wide uniqueness. Changing this for existing buckets requires a reviewed migration. | `string` | `"project-id"` | no | | [federated\_identity\_providers](#input\_federated\_identity\_providers) | List of federated identity providers to configure for the management service account. |
list(object({
name = string
issuer = string
assertions = list(object({
item = string
operator = string
value = string
}))
}))
| `[]` | no | | [labels](#input\_labels) | Additional labels to apply to all folders. | `map(string)` | `{}` | no | | [naming\_pattern](#input\_naming\_pattern) | Naming prefix for all resources in this module, e.g. "myco-pltfm-hub-prod". | `string` | n/a | yes | -| [observability](#input\_observability) | Observability instance configuration. Set to null to skip observability deployment. |
object({
plan_name = optional(string, "Observability-Starter-EU01")
acl = optional(list(string), [])
logs_retention_days = optional(number, 30)
traces_retention_days = optional(number, 30)
metrics_retention_days = optional(number, 90)
metrics_retention_days_5m_downsampling = optional(number, 90)
metrics_retention_days_1h_downsampling = optional(number, 90)
})
| `null` | no | +| [observability](#input\_observability) | Observability instance configuration. Omitted or null retention uses the service plan defaults. Monitoring plans require both retention fields to be omitted or null. Set to null to skip observability deployment. |
object({
plan_name = optional(string, "Observability-Starter-EU01")
acl = optional(list(string), [])
logs_retention_days = optional(number)
traces_retention_days = optional(number)
metrics_retention_days = optional(number)
metrics_retention_days_5m_downsampling = optional(number)
metrics_retention_days_1h_downsampling = optional(number)
})
| `null` | no | | [organization\_id](#input\_organization\_id) | Container ID of the root folder or organization under which the company folder will be created. | `string` | n/a | yes | | [owner\_email](#input\_owner\_email) | Email address of the owner for the folders. Required for STACKIT resource manager. | `string` | n/a | yes | | [parent\_container\_id](#input\_parent\_container\_id) | Parent container ID (folder or organization) where the project will be created. | `string` | n/a | yes | diff --git a/src/modules/management/README.md b/src/modules/management/README.md index c5db900f..b175de12 100644 --- a/src/modules/management/README.md +++ b/src/modules/management/README.md @@ -66,10 +66,11 @@ No modules. | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | | [audit\_logs](#input\_audit\_logs) | Audit logs configuration. The router forwards to two destinations: OTLP into the Logs instance for querying, and S3 into the audit bucket for long-term archive. retention\_days applies to both, driving the Logs instance retention and, when s3\_object\_lock is enabled, the archive bucket's default retention. link\_scopes defaults to a single organization-wide link; set it to attach individual folders or projects instead. |
object({
retention_days = optional(number, 180)
acl = optional(list(string), null)
s3_object_lock = optional(bool, true)
link_scopes = optional(list(object({
resource_type = string # organization, folder, project
resource_id = string
})), null)
})
| `null` | no | +| [bucket\_naming](#input\_bucket\_naming) | Management bucket naming scheme: legacy preserves existing names; project-id includes the complete management project UUID for STACKIT-wide uniqueness. Changing this for existing buckets requires a reviewed migration. | `string` | `"project-id"` | no | | [federated\_identity\_providers](#input\_federated\_identity\_providers) | List of federated identity providers to configure for the management service account. |
list(object({
name = string
issuer = string
assertions = list(object({
item = string
operator = string
value = string
}))
}))
| `[]` | no | | [labels](#input\_labels) | Additional labels to apply to all folders. | `map(string)` | `{}` | no | | [naming\_pattern](#input\_naming\_pattern) | Naming prefix for all resources in this module, e.g. "myco-pltfm-hub-prod". | `string` | n/a | yes | -| [observability](#input\_observability) | Observability instance configuration. Set to null to skip observability deployment. |
object({
plan_name = optional(string, "Observability-Starter-EU01")
acl = optional(list(string), [])
logs_retention_days = optional(number, 30)
traces_retention_days = optional(number, 30)
metrics_retention_days = optional(number, 90)
metrics_retention_days_5m_downsampling = optional(number, 90)
metrics_retention_days_1h_downsampling = optional(number, 90)
})
| `null` | no | +| [observability](#input\_observability) | Observability instance configuration. Omitted or null retention uses the service plan defaults. Monitoring plans require both retention fields to be omitted or null. Set to null to skip observability deployment. |
object({
plan_name = optional(string, "Observability-Starter-EU01")
acl = optional(list(string), [])
logs_retention_days = optional(number)
traces_retention_days = optional(number)
metrics_retention_days = optional(number)
metrics_retention_days_5m_downsampling = optional(number)
metrics_retention_days_1h_downsampling = optional(number)
})
| `null` | no | | [organization\_id](#input\_organization\_id) | Container ID of the root folder or organization under which the company folder will be created. | `string` | n/a | yes | | [owner\_email](#input\_owner\_email) | Email address of the owner for the folders. Required for STACKIT resource manager. | `string` | n/a | yes | | [parent\_container\_id](#input\_parent\_container\_id) | Parent container ID (folder or organization) where the project will be created. | `string` | n/a | yes | diff --git a/src/modules/management/variables.tf b/src/modules/management/variables.tf index 8c527d06..36f9b1df 100644 --- a/src/modules/management/variables.tf +++ b/src/modules/management/variables.tf @@ -1,3 +1,14 @@ +variable "bucket_naming" { + type = string + description = "Management bucket naming scheme: legacy preserves existing names; project-id includes the complete management project UUID for STACKIT-wide uniqueness. Changing this for existing buckets requires a reviewed migration." + default = "project-id" + + validation { + condition = contains(["legacy", "project-id"], var.bucket_naming) + error_message = "Bucket naming must be legacy or project-id." + } +} + variable "project_name" { type = string description = "Name of the STACKIT project to create." @@ -34,14 +45,44 @@ variable "observability" { type = object({ plan_name = optional(string, "Observability-Starter-EU01") acl = optional(list(string), []) - logs_retention_days = optional(number, 30) - traces_retention_days = optional(number, 30) - metrics_retention_days = optional(number, 90) - metrics_retention_days_5m_downsampling = optional(number, 90) - metrics_retention_days_1h_downsampling = optional(number, 90) + logs_retention_days = optional(number) + traces_retention_days = optional(number) + metrics_retention_days = optional(number) + metrics_retention_days_5m_downsampling = optional(number) + metrics_retention_days_1h_downsampling = optional(number) }) - description = "Observability instance configuration. Set to null to skip observability deployment." + description = "Observability instance configuration. Omitted or null retention uses the service plan defaults. Monitoring plans require both retention fields to be omitted or null. Set to null to skip observability deployment." default = null + + validation { + condition = var.observability == null ? true : ( + !startswith(var.observability.plan_name, "Observability-Monitoring-") || + (var.observability.logs_retention_days == null && var.observability.traces_retention_days == null) + ) + error_message = "Monitoring plans do not support logs or traces retention. Omit logs_retention_days and traces_retention_days or set them to null." + } + + validation { + condition = var.observability == null ? true : ( + !can(regex("^Observability-(Frontend|Metrics-?Endpoint)", var.observability.plan_name)) || + alltrue([for value in [var.observability.logs_retention_days, var.observability.traces_retention_days, var.observability.metrics_retention_days, var.observability.metrics_retention_days_5m_downsampling, var.observability.metrics_retention_days_1h_downsampling] : value == null]) + ) + error_message = "Frontend and MetricsEndpoint plans do not support retention overrides. Omit all retention fields or set them to null." + } + + validation { + condition = var.observability == null ? true : alltrue([ + for value in [var.observability.logs_retention_days, var.observability.traces_retention_days] : value == null ? true : value >= 1 && value <= 30 && floor(value) == value + ]) + error_message = "Logs and traces retention must be omitted, null or a whole number from 1 to 30 days." + } + + validation { + condition = var.observability == null ? true : alltrue([ + for value in [var.observability.metrics_retention_days, var.observability.metrics_retention_days_5m_downsampling, var.observability.metrics_retention_days_1h_downsampling] : value == null ? true : value >= 1 && value <= 780 && floor(value) == value + ]) + error_message = "Metrics retention must be omitted, null or a whole number from 1 to 780 days." + } } variable "role_assignments" { diff --git a/src/tests/standalone.tftest.hcl b/src/tests/standalone.tftest.hcl index 0cf521f5..28fbdf16 100644 --- a/src/tests/standalone.tftest.hcl +++ b/src/tests/standalone.tftest.hcl @@ -401,3 +401,209 @@ run "standalone_local_project_network" { error_message = "A local project network must not require connectivity or an SNA." } } + +run "management_monitoring_nullable_retention" { + command = plan + + module { + source = "./modules/management" + } + + variables { + naming_pattern = "tst-pltfm-mgmt-prod" + parent_container_id = "00000000-0000-0000-0000-000000000000" + observability = { + plan_name = "Observability-Monitoring-Medium-EU01" + logs_retention_days = null + traces_retention_days = null + } + } + + assert { + condition = var.observability.logs_retention_days == null && var.observability.traces_retention_days == null && try(var.observability.metrics_retention_days, null) == null + error_message = "Monitoring must preserve omitted/null logs and traces retention while leaving metrics at the service default." + } +} + +run "management_monitoring_rejects_logs_retention" { + command = plan + + module { + source = "./modules/management" + } + + variables { + naming_pattern = "tst-pltfm-mgmt-prod" + parent_container_id = "00000000-0000-0000-0000-000000000000" + observability = { + plan_name = "Observability-Monitoring-Medium-EU01" + logs_retention_days = 14 + } + } + + expect_failures = [var.observability] +} + +run "management_observability_preserves_explicit_retention" { + command = plan + + module { + source = "./modules/management" + } + + variables { + naming_pattern = "tst-pltfm-mgmt-prod" + parent_container_id = "00000000-0000-0000-0000-000000000000" + observability = { + plan_name = "Observability-Medium-EU01" + logs_retention_days = 14 + traces_retention_days = 7 + } + } + + assert { + condition = stackit_observability_instance.this[0].logs_retention_days == 14 && stackit_observability_instance.this[0].traces_retention_days == 7 + error_message = "Explicit retention values must survive unchanged for a full Observability plan." + } +} + +run "root_monitoring_omits_unsupported_retention" { + command = plan + + variables { + observability = { + plan_name = "Observability-Monitoring-Medium-EU01" + } + } + + assert { + condition = try(var.observability.logs_retention_days, null) == null && try(var.observability.traces_retention_days, null) == null + error_message = "Root defaults must not inject unsupported log or trace retention into Monitoring plans." + } +} + +run "root_monitoring_rejects_traces_retention" { + command = plan + + variables { + observability = { + plan_name = "Observability-Monitoring-Medium-EU01" + traces_retention_days = 14 + } + } + + expect_failures = [var.observability] +} + +run "management_project_unique_bucket_defaults" { + command = plan + + module { + source = "./modules/management" + } + + variables { + naming_pattern = "a-very-long-but-valid-management-resource-name-prefix" + parent_container_id = "00000000-0000-0000-0000-000000000000" + } + + assert { + condition = alltrue([ + for name in [stackit_objectstorage_bucket.default.name, stackit_objectstorage_bucket.tfstate.name, stackit_objectstorage_bucket.audit_logs.name] : + length(name) <= 63 && strcontains(name, stackit_resourcemanager_project.this.project_id) + ]) && length(toset([stackit_objectstorage_bucket.default.name, stackit_objectstorage_bucket.tfstate.name, stackit_objectstorage_bucket.audit_logs.name])) == 3 + error_message = "All bucket defaults must contain the full project UUID, remain distinct and fit the S3 name limit." + } +} + +run "management_legacy_bucket_names_require_explicit_choice" { + command = plan + + module { + source = "./modules/management" + } + + variables { + naming_pattern = "tst-pltfm-mgmt-prod" + parent_container_id = "00000000-0000-0000-0000-000000000000" + bucket_naming = "legacy" + } + + assert { + condition = stackit_objectstorage_bucket.default.name == "tst-pltfm-mgmt-prod-default" && stackit_objectstorage_bucket.tfstate.name == "tst-pltfm-mgmt-prod-tfstate" && stackit_objectstorage_bucket.audit_logs.name == "tst-pltfm-mgmt-prod-audit-logs" + error_message = "Explicit legacy mode must retain all existing bucket names exactly." + } +} + +run "frontend_omits_all_retention_overrides" { + command = plan + + variables { + observability = { + plan_name = "Observability-Frontend-Starter-EU01" + } + } +} + +run "frontend_rejects_metrics_override" { + command = plan + + variables { + observability = { + plan_name = "Observability-Frontend-Starter-EU01" + metrics_retention_days = 90 + } + } + + expect_failures = [var.observability] +} + +run "observability_rejects_invalid_trace_retention" { + command = plan + + variables { + observability = { + traces_retention_days = 31 + } + } + + expect_failures = [var.observability] +} + +run "management_identical_prefix_distinct_project" { + command = plan + + module { + source = "./modules/management" + } + + variables { + naming_pattern = "a-very-long-but-valid-management-resource-name-prefix" + parent_container_id = "00000000-0000-0000-0000-000000000000" + } + + override_resource { + target = stackit_resourcemanager_project.this + values = { + project_id = "99999999-9999-4999-8999-999999999999" + } + } + + assert { + condition = stackit_objectstorage_bucket.tfstate.name != "${substr(var.naming_pattern, 0, 18)}-11111111-1111-4111-8111-111111111111-tfstate" && strcontains(stackit_objectstorage_bucket.tfstate.name, "99999999-9999-4999-8999-999999999999") + error_message = "Identical naming prefixes in distinct projects must produce distinct bucket names using the full immutable UUID." + } +} + +run "metrics_endpoint_rejects_retention_override" { + command = plan + + variables { + observability = { + plan_name = "Observability-Metrics-Endpoint-100k-EU01" + metrics_retention_days = 90 + } + } + + expect_failures = [var.observability] +} diff --git a/src/variables.tf b/src/variables.tf index e83ce485..e7f9bf83 100644 --- a/src/variables.tf +++ b/src/variables.tf @@ -1,3 +1,14 @@ +variable "bucket_naming" { + type = string + description = "Object Storage bucket naming scheme: legacy preserves existing names; project-id includes the complete owning project UUID for STACKIT-wide uniqueness. Changing this for existing buckets requires a reviewed migration." + default = "project-id" + + validation { + condition = contains(["legacy", "project-id"], var.bucket_naming) + error_message = "Bucket naming must be legacy or project-id." + } +} + ############# ## GENERAL ## ############# @@ -185,14 +196,44 @@ variable "observability" { type = object({ plan_name = optional(string, "Observability-Starter-EU01") acl = optional(list(string), []) - logs_retention_days = optional(number, 30) - traces_retention_days = optional(number, 30) - metrics_retention_days = optional(number, 90) - metrics_retention_days_5m_downsampling = optional(number, 90) - metrics_retention_days_1h_downsampling = optional(number, 90) + logs_retention_days = optional(number) + traces_retention_days = optional(number) + metrics_retention_days = optional(number) + metrics_retention_days_5m_downsampling = optional(number) + metrics_retention_days_1h_downsampling = optional(number) }) - description = "Observability instance configuration for the management module. Set to null to skip observability deployment." + description = "Observability instance configuration for the management module. Omitted or null retention uses the service plan defaults. Monitoring plans require both retention fields to be omitted or null. Set to null to skip observability deployment." default = null + + validation { + condition = var.observability == null ? true : ( + !startswith(var.observability.plan_name, "Observability-Monitoring-") || + (var.observability.logs_retention_days == null && var.observability.traces_retention_days == null) + ) + error_message = "Monitoring plans do not support logs or traces retention. Omit logs_retention_days and traces_retention_days or set them to null." + } + + validation { + condition = var.observability == null ? true : ( + !can(regex("^Observability-(Frontend|Metrics-?Endpoint)", var.observability.plan_name)) || + alltrue([for value in [var.observability.logs_retention_days, var.observability.traces_retention_days, var.observability.metrics_retention_days, var.observability.metrics_retention_days_5m_downsampling, var.observability.metrics_retention_days_1h_downsampling] : value == null]) + ) + error_message = "Frontend and MetricsEndpoint plans do not support retention overrides. Omit all retention fields or set them to null." + } + + validation { + condition = var.observability == null ? true : alltrue([ + for value in [var.observability.logs_retention_days, var.observability.traces_retention_days] : value == null ? true : value >= 1 && value <= 30 && floor(value) == value + ]) + error_message = "Logs and traces retention must be omitted, null or a whole number from 1 to 30 days." + } + + validation { + condition = var.observability == null ? true : alltrue([ + for value in [var.observability.metrics_retention_days, var.observability.metrics_retention_days_5m_downsampling, var.observability.metrics_retention_days_1h_downsampling] : value == null ? true : value >= 1 && value <= 780 && floor(value) == value + ]) + error_message = "Metrics retention must be omitted, null or a whole number from 1 to 780 days." + } } variable "audit_logs" {