diff --git a/.github/workflows/pack-distribution.yml b/.github/workflows/pack-distribution.yml index 2bda46d..4c33cfc 100644 --- a/.github/workflows/pack-distribution.yml +++ b/.github/workflows/pack-distribution.yml @@ -1,12 +1,13 @@ # Verified pack supply-chain rehearsal (issue #191, ADR 0037). # # Manually triggered end-to-end integration for the distribution workflow. It -# stands up an ephemeral OCI registry (registry:2), publishes TechVault with its -# generated SBOM + provenance, signs the validated set-digest subject with -# keyless Sigstore (the GitHub Actions OIDC identity — the one thing a developer -# box cannot provide), then pulls, verifies signature + content, installs, and -# checks update/rollback guidance. Nothing here is standing infrastructure: the -# registry and the signing identity are spun up per run. +# stands up an ephemeral OCI registry (registry:2), publishes the pack directory +# named by the `pack` input (TechVault by default) with its generated SBOM + +# provenance, signs the validated set-digest subject with keyless Sigstore (the +# GitHub Actions OIDC identity — the one thing a developer box cannot provide), +# then pulls, verifies signature + content, installs, and checks update/rollback +# guidance. Nothing here is standing infrastructure: the registry and the signing +# identity are spun up per run. # # The workflow *shape* (triggers, permissions, pinning, step order) is guarded by # tests/test_pack_distribution_workflow.py, and the real acceptance criterion — @@ -68,4 +69,7 @@ jobs: python -m pip install --require-hashes -r requirements/runtime.txt python -m pip install --no-deps -e . - name: Run the pack-distribution rehearsal + env: + # The dispatch value reaches the script as data, never as shell text. + REHEARSAL_PACK: ${{ inputs.pack }} run: python tests_integration/distribution_rehearsal.py diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 5c753b4..d06ed2b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "6.3.0" + ".": "6.3.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f73e26..092935c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ this file at release-prep. See [`changelog.d/README.md`](changelog.d/README.md). +## [6.3.1](https://github.com/OpenRAE/env-packs/compare/v6.3.0...v6.3.1) (2026-10-14) + + +### Bug Fixes + +* **ci:** derive the distribution rehearsal's release paths from the pack version ([#453](https://github.com/OpenRAE/env-packs/issues/453)) ([812b8ef](https://github.com/OpenRAE/env-packs/commit/812b8ef37520b2fb3cce02bac0d1f5a26f7ae4b4)), closes [#452](https://github.com/OpenRAE/env-packs/issues/452) +* correct techvault MISP sync API key fixtures ([#461](https://github.com/OpenRAE/env-packs/issues/461)) ([eb64eef](https://github.com/OpenRAE/env-packs/commit/eb64eefca4f984e2dba8df4102167893d0e98178)) +* **release:** report a pack.yaml contents or profile_bundles value that is not a mapping instead of crashing lint ([#456](https://github.com/OpenRAE/env-packs/issues/456)) ([9a5c065](https://github.com/OpenRAE/env-packs/commit/9a5c06558c168e4cc062b8db69175a78651cc629)) + ## [6.3.0](https://github.com/OpenRAE/env-packs/compare/v6.2.1...v6.3.0) (2026-10-13) diff --git a/docs/public/distribution.md b/docs/public/distribution.md index eeed705..88468d7 100644 --- a/docs/public/distribution.md +++ b/docs/public/distribution.md @@ -41,7 +41,8 @@ fails the build. Signing and pushing to a registry use established tooling (cosign, oras) and a keyless Sigstore identity, so they run in CI, not on a laptop. The `Pack distribution rehearsal` workflow does the full publish → sign → install → -verify pass against an ephemeral registry. +verify pass against an ephemeral registry, for the pack directory named by its +`pack` input (default `packs/techvault`). ## Verify diff --git a/kits/infrastructure.smtp-imap-mail-service/1.2.0/README.md b/kits/infrastructure.smtp-imap-mail-service/1.2.0/README.md new file mode 100644 index 0000000..e88e6de --- /dev/null +++ b/kits/infrastructure.smtp-imap-mail-service/1.2.0/README.md @@ -0,0 +1,7 @@ +# SMTP/IMAP mail service + +Reusable smtp/imap mail service with domain-specific configuration, declared service surfaces, seeded objects, and pack-local integration material. + +The release includes a RAES module, explicit service and identity surfaces, benign seed inventory, planning estimates, component-inventory inputs, integration guidance, and composition tests. + +It is authoring content, not a runtime plugin, scenario narrative, backend qualification, or evidence claim. diff --git a/kits/infrastructure.smtp-imap-mail-service/1.2.0/assets/integration.md b/kits/infrastructure.smtp-imap-mail-service/1.2.0/assets/integration.md new file mode 100644 index 0000000..cefd1f1 --- /dev/null +++ b/kits/infrastructure.smtp-imap-mail-service/1.2.0/assets/integration.md @@ -0,0 +1,15 @@ +# SMTP/IMAP mail service integration material + +Author parameter: `mail_domain` (default `environment.test`). + +## Exported RAES declarations + +- `nodes.mail` +- `content.seed_inventory` +- `accounts.mail_operator` + +## Composition notes + +- No mandatory external authoring prerequisite; connect exported surfaces at the pack composition root as needed. +- Keep credentials and runtime-selected endpoints outside kit parameters and seed assets. +- Validate the completed ordinary pack after adding pack-level relationships. diff --git a/kits/infrastructure.smtp-imap-mail-service/1.2.0/assets/seed.yaml b/kits/infrastructure.smtp-imap-mail-service/1.2.0/assets/seed.yaml new file mode 100644 index 0000000..9cebeae --- /dev/null +++ b/kits/infrastructure.smtp-imap-mail-service/1.2.0/assets/seed.yaml @@ -0,0 +1,22 @@ +schema_version: environment-kit-seed/v1 +kit: infrastructure.smtp-imap-mail-service +configuration: + mail_domain: environment.test + deployment_profile: standard + service_label: smtp-imap-mail-service +declared_objects: +- id: mail_domain + kind: infrastructure-seed + sensitivity: non-secret +- id: mailboxes + kind: infrastructure-seed + sensitivity: non-secret +- id: aliases + kind: infrastructure-seed + sensitivity: non-secret +- id: seed_messages + kind: infrastructure-seed + sensitivity: non-secret +integration_requirements: +- No mandatory external authoring prerequisite; connect exported surfaces at the pack + composition root as needed. diff --git a/kits/infrastructure.smtp-imap-mail-service/1.2.0/associated-artifacts.json b/kits/infrastructure.smtp-imap-mail-service/1.2.0/associated-artifacts.json new file mode 100644 index 0000000..a2399ce --- /dev/null +++ b/kits/infrastructure.smtp-imap-mail-service/1.2.0/associated-artifacts.json @@ -0,0 +1,113 @@ +{ + "schema_version": "associated-artifact-manifest/v1", + "manifest_id": "infrastructure.smtp-imap-mail-service-associated-artifacts", + "manifest_version": "1.2.0", + "canonicalization_profile": "associated-artifact-set/v1", + "scope": "scenario", + "parent_ref": { + "ref_kind": "scenario-snapshot", + "ref_id": "smtp-imap-mail-service", + "ref_version": null, + "ref_digest": "sha256:38ad49c3567fb8d6a1ef257d7a26e7a19a906f0e53d66102a04466759a4e5915", + "ref_path": null + }, + "artifacts": { + "readme": { + "artifact_id": "readme", + "role": "documentation", + "media_type": "text/markdown", + "uri": "raes-environment-kit:/README.md", + "checksum": { + "algorithm": "sha256", + "value": "d5ca58ea7130283318c6a3b7685dc711ebf805b0e3433b9d37feba90ee513c38" + }, + "size_bytes": 477, + "created_at": "2026-10-15T00:00:00Z", + "source": "infrastructure.smtp-imap-mail-service@1.2.0", + "satisfies_refs": [], + "sensitivity": "public", + "description": null + }, + "integration-material": { + "artifact_id": "integration-material", + "role": "operator-guide", + "media_type": "text/markdown", + "uri": "raes-environment-kit:/assets/integration.md", + "checksum": { + "algorithm": "sha256", + "value": "5370704a583607c25e92e340af96d060a8613df9bb2422e09cfce0c9694c237f" + }, + "size_bytes": 515, + "created_at": "2026-10-15T00:00:00Z", + "source": "infrastructure.smtp-imap-mail-service@1.2.0", + "satisfies_refs": [], + "sensitivity": "public", + "description": null + }, + "seed-profile": { + "artifact_id": "seed-profile", + "role": "configuration", + "media_type": "application/yaml", + "uri": "raes-environment-kit:/assets/seed.yaml", + "checksum": { + "algorithm": "sha256", + "value": "373971e0ebc64634f4b5c61f6303e7d2d9db6e42c38a59d1342355ac996b0da0" + }, + "size_bytes": 645, + "created_at": "2026-10-15T00:00:00Z", + "source": "infrastructure.smtp-imap-mail-service@1.2.0", + "satisfies_refs": [], + "sensitivity": "public", + "description": null + }, + "kit-manifest": { + "artifact_id": "kit-manifest", + "role": "manifest", + "media_type": "application/yaml", + "uri": "raes-environment-kit:/kit.yaml", + "checksum": { + "algorithm": "sha256", + "value": "95983ba6063239f7b2321fb88cd5bbedee6c7d172139b065cd15193c4a0d8812" + }, + "size_bytes": 1678, + "created_at": "2026-10-15T00:00:00Z", + "source": "infrastructure.smtp-imap-mail-service@1.2.0", + "satisfies_refs": [], + "sensitivity": "public", + "description": null + }, + "module": { + "artifact_id": "module", + "role": "configuration", + "media_type": "application/yaml", + "uri": "raes-environment-kit:/module.sdl.yaml", + "checksum": { + "algorithm": "sha256", + "value": "4efabb0a668db232bc58e93a87340681b64ebcdfcfc20335f0dc7ea62c179271" + }, + "size_bytes": 3086, + "created_at": "2026-10-15T00:00:00Z", + "source": "infrastructure.smtp-imap-mail-service@1.2.0", + "satisfies_refs": [], + "sensitivity": "public", + "description": null + }, + "composition-tests": { + "artifact_id": "composition-tests", + "role": "configuration", + "media_type": "application/yaml", + "uri": "raes-environment-kit:/tests/composition.yaml", + "checksum": { + "algorithm": "sha256", + "value": "d70f4fd395197c38a5b9ffdf68b71a6f9feca59059ed073e3c3fc1409b82d5f6" + }, + "size_bytes": 265, + "created_at": "2026-10-15T00:00:00Z", + "source": "infrastructure.smtp-imap-mail-service@1.2.0", + "satisfies_refs": [], + "sensitivity": "public", + "description": null + } + }, + "set_digest": "sha256:fd64f338c30e66354aef2f7b7de0afd5ed9e9defa60e0bbcc4227b239a9aa71f" +} diff --git a/kits/infrastructure.smtp-imap-mail-service/1.2.0/kit.yaml b/kits/infrastructure.smtp-imap-mail-service/1.2.0/kit.yaml new file mode 100644 index 0000000..2e0443c --- /dev/null +++ b/kits/infrastructure.smtp-imap-mail-service/1.2.0/kit.yaml @@ -0,0 +1,49 @@ +schema_version: environment-pack-kit/v1 +id: infrastructure.smtp-imap-mail-service +version: 1.2.0 +title: SMTP/IMAP mail service +summary: Reusable smtp/imap mail service with domain-specific configuration, declared + service surfaces, seeded objects, and pack-local integration material. +concern: network-shared +released_at: '2026-10-15T00:00:00Z' +module: + path: module.sdl.yaml +assets: +- source: assets/integration.md + target: assets/kits/smtp-imap-mail-service/integration.md + visibility: operator + artifact_id: integration-material +- source: assets/seed.yaml + target: assets/kits/smtp-imap-mail-service/seed.yaml + visibility: operator + artifact_id: seed-profile +resources: + cpu_cores: 2 + memory_mib: 2048 + storage_mib: 4096 + notes: Planning estimates only; realization sizing remains backend-owned. +prerequisites: [] +limitations: +- Declares static infrastructure, seeded state, and integration surfaces only. +- Does not claim launch, readiness, traffic attachment, credential delivery, or runtime + evidence. +- No mandatory external authoring prerequisite; connect exported surfaces at the pack + composition root as needed. +license: + expression: MIT + redistribution: open + attribution: OpenRAE contributors +tests: +- path: tests/composition.yaml + kind: validate +- path: tests/composition.yaml + kind: parameter-variation +- path: tests/composition.yaml + kind: multi-kit +component_inventory: +- scope: unresolved + authority: raes-source + ref: /nodes/mail/source + description: RAES carries the declared mail-server stable source; immutable artifact + selection remains unresolved until pack publication. +associated_artifact_manifest: associated-artifacts.json diff --git a/kits/infrastructure.smtp-imap-mail-service/1.2.0/module.sdl.yaml b/kits/infrastructure.smtp-imap-mail-service/1.2.0/module.sdl.yaml new file mode 100644 index 0000000..7157410 --- /dev/null +++ b/kits/infrastructure.smtp-imap-mail-service/1.2.0/module.sdl.yaml @@ -0,0 +1,117 @@ +name: smtp-imap-mail-service +version: 1.2.0 +description: Reusable static infrastructure for SMTP/IMAP mail service. Configures + mail_domain through the kit parameter contract. +module: + id: infrastructure/smtp-imap-mail-service + version: 1.2.0 + parameters: + - deployment_profile + - service_label + - mail_domain + exports: + nodes: + - mail + content: + - seed_inventory + accounts: + - mail_operator + description: Composable SMTP/IMAP mail service infrastructure module. +variables: + deployment_profile: + type: string + default: standard + allowed_values: + - compact + - standard + service_label: + type: string + default: smtp-imap-mail-service + mail_domain: + type: string + default: environment.test +nodes: + mail: + type: compute + description: '${deployment_profile} profile for ${service_label} (SMTP/IMAP mail + service). Configuration: ${mail_domain}.' + source: + name: mail-server + version: stable + resources: + cpu: 2 + ram: 2 GiB + services: + - port: 25 + protocol: tcp + name: smtp + description: Declared smtp service surface. + - port: 143 + protocol: tcp + name: imap + description: Declared imap service surface. + runtime: + mail_services: + - mail_service_id: environment_mail + service: smtp + engine: postfix-dovecot + listeners: + - listener_id: smtp_listener + service: smtp + protocol: smtp + tls_mode: starttls-available + - listener_id: imap_listener + service: imap + protocol: imap + tls_mode: starttls-available + domains: + - domain_id: primary_domain + name: ${mail_domain} + mailboxes: + - mailbox_id: operator_mailbox + address: operator@${mail_domain} + domain_ref: primary_domain + - mailbox_id: service_mailbox + address: service@${mail_domain} + domain_ref: primary_domain +content: + seed_inventory: + type: dataset + target: mail + description: Benign infrastructure seed inventory for ${mail_domain}. + items: + - name: mail_domain + display_name: Mail Domain + tags: + - infrastructure + - seed + description: Declared mail domain for this reusable service. + - name: mailboxes + display_name: Mailboxes + tags: + - infrastructure + - seed + description: Declared mailboxes for this reusable service. + - name: aliases + display_name: Aliases + tags: + - infrastructure + - seed + description: Declared aliases for this reusable service. + - name: seed_messages + display_name: Seed Messages + tags: + - infrastructure + - seed + description: Declared seed messages for this reusable service. +accounts: + mail_operator: + username: mail-operator + node: mail + password_strength: strong + description: Declared environment account; credential delivery remains backend-owned. +realization: + constraints: + - field_pointer: /nodes/mail + concern: compute-substrate + posture: open diff --git a/kits/infrastructure.smtp-imap-mail-service/1.2.0/tests/composition.yaml b/kits/infrastructure.smtp-imap-mail-service/1.2.0/tests/composition.yaml new file mode 100644 index 0000000..305a51d --- /dev/null +++ b/kits/infrastructure.smtp-imap-mail-service/1.2.0/tests/composition.yaml @@ -0,0 +1,9 @@ +default: + deployment_profile: standard + service_label: smtp-imap-mail-service + mail_domain: environment.test +variation: + deployment_profile: compact + service_label: smtp-imap-mail-service-alternate + mail_domain: alternate.test +multi_kit_group: network-shared diff --git a/packs/ai-escape-lab/associated-artifacts.json b/packs/ai-escape-lab/associated-artifacts.json index 8c221b9..b775ed1 100644 --- a/packs/ai-escape-lab/associated-artifacts.json +++ b/packs/ai-escape-lab/associated-artifacts.json @@ -19,9 +19,9 @@ "uri": "raes-environment-pack:/README.md", "checksum": { "algorithm": "sha256", - "value": "eb2945ac5115040a7c9bce97533ed2617bc376e9f498e52e265959034aa35046" + "value": "140711d3e3e1af6b98115e6d8cb9e527c1626e4aeb91ca339b2ebba6f11d0f44" }, - "size_bytes": 2227, + "size_bytes": 2233, "created_at": "2026-10-06T00:00:00Z", "source": "environment-pack-author", "satisfies_refs": [], @@ -211,9 +211,9 @@ "uri": "raes-environment-pack:/build/gcp/scripts/prepare-source.py", "checksum": { "algorithm": "sha256", - "value": "1dd0ebd18e756c2a5b538c8d3871f79a400ed74e3c800ab0451453c4f2c942f8" + "value": "553ced3b0b3b01d035b66da4209d5570da298dbdaa9ab371419736b960c81859" }, - "size_bytes": 4550, + "size_bytes": 5227, "created_at": "2026-10-06T00:00:00Z", "source": "environment-pack-author", "satisfies_refs": [], @@ -259,9 +259,9 @@ "uri": "raes-environment-pack:/build/runtime/participant-briefing.md", "checksum": { "algorithm": "sha256", - "value": "c6a5cee2ea059890c02e936fa5eb83938bc4a21ce116f9f8e37761a533573958" + "value": "228906fe3dc15c7b5d82763e960a937e24369f3b910b57a89e05e77f12364e76" }, - "size_bytes": 1461, + "size_bytes": 1640, "created_at": "2026-10-06T00:00:00Z", "source": "environment-pack-author", "satisfies_refs": [], @@ -307,7 +307,7 @@ "uri": "raes-environment-pack:/build/runtime/readiness.json", "checksum": { "algorithm": "sha256", - "value": "f2307b60a0ef4d8d7e5caccc5fbacb722ef0b612edec937c975eb3d96d411167" + "value": "c5a538362e40fd3de321c078c987db3ac8148661407e351b9678b7afb14dddd4" }, "size_bytes": 1264, "created_at": "2026-10-06T00:00:00Z", @@ -323,9 +323,9 @@ "uri": "raes-environment-pack:/build/runtime/readiness.sh", "checksum": { "algorithm": "sha256", - "value": "9e572c4c395806dc9076236f86dee96f9394f5aec968c8c51a03395e65cc88aa" + "value": "e459e8890f60d2f588f4e1ac5344bf85edd2e629f5e6d7b44cc1e9cb46081364" }, - "size_bytes": 1647, + "size_bytes": 1548, "created_at": "2026-10-06T00:00:00Z", "source": "environment-pack-author", "satisfies_refs": [], @@ -403,9 +403,9 @@ "uri": "raes-environment-pack:/build/tests/test_build.py", "checksum": { "algorithm": "sha256", - "value": "d7d38cd2c34ca7eff2e1d0e8610e9a93cddd5a4b047007c5ccc13ba8c5ff67d8" + "value": "74258809900db6f2118f5fdc1146c8f35df3fa74de646511da294cfe9f1d510a" }, - "size_bytes": 10774, + "size_bytes": 12055, "created_at": "2026-10-06T00:00:00Z", "source": "environment-pack-author", "satisfies_refs": [], @@ -419,9 +419,9 @@ "uri": "raes-environment-pack:/challenges/challenges.yaml", "checksum": { "algorithm": "sha256", - "value": "6d04a7404dcff45f17054d8ab08a7030ad3f7b95a7124ba8e3eb66a01f14746d" + "value": "803f798b60b9e5d1b509d44d9d04f1bf6b13ade4464636ff6159eda41fea2fd2" }, - "size_bytes": 2833, + "size_bytes": 4456, "created_at": "2026-10-06T00:00:00Z", "source": "environment-pack-author", "satisfies_refs": [], @@ -467,9 +467,9 @@ "uri": "raes-environment-pack:/ctfd/tests/test_export.py", "checksum": { "algorithm": "sha256", - "value": "c867eb0f3ba972f28d4b22ec72bcdee0bc3e35d0ba31de84f38534ca122ce1f9" + "value": "93ab435322ec4a02398271b00a8e3dc9afe3ec343d72611f4e7181aba56b25d5" }, - "size_bytes": 835, + "size_bytes": 937, "created_at": "2026-10-06T00:00:00Z", "source": "environment-pack-author", "satisfies_refs": [], @@ -531,9 +531,9 @@ "uri": "raes-environment-pack:/docs/golden-readiness-checklist.md", "checksum": { "algorithm": "sha256", - "value": "eccf6bd2c7602d1ffee48c552f0e474b99e8136264bc268a1f868175c7e2ffa6" + "value": "63fefe8aac3c426366dea28a0dca4574c6826fb3b768876e540bf7d35bbf29b8" }, - "size_bytes": 3115, + "size_bytes": 3125, "created_at": "2026-10-06T00:00:00Z", "source": "environment-pack-author", "satisfies_refs": [], @@ -547,9 +547,9 @@ "uri": "raes-environment-pack:/docs/lineage.md", "checksum": { "algorithm": "sha256", - "value": "72983235321805759dc59a611868457988c2568533ecff8e3b5b618a0ce8b8aa" + "value": "1bbe5d9cac1e5bb5bd210abfd946a1268b64e30dbfff9189ef117f9a9a57b385" }, - "size_bytes": 492, + "size_bytes": 609, "created_at": "2026-10-06T00:00:00Z", "source": "environment-pack-author", "satisfies_refs": [], @@ -563,9 +563,9 @@ "uri": "raes-environment-pack:/docs/operator-runbook.md", "checksum": { "algorithm": "sha256", - "value": "8a4183d5b3ba178bd4bab51acb8b298e0863e15320c721c505bf05ab8831011c" + "value": "14f702b0f53388511fe77f076aaa0a6a74e9d61ba9bb485c9b79e725cbf3ac65" }, - "size_bytes": 6942, + "size_bytes": 6969, "created_at": "2026-10-06T00:00:00Z", "source": "environment-pack-author", "satisfies_refs": [], @@ -579,9 +579,9 @@ "uri": "raes-environment-pack:/docs/provenance-ledger.yaml", "checksum": { "algorithm": "sha256", - "value": "6ba38c526b47508bf06c42bcc0fa2e03cf589ed70d585fc57dbf1d5bbe5d73cb" + "value": "9d87e7707f817c7673dd33d8b24e7024a8f816504fe7486a54acd29a910549bc" }, - "size_bytes": 2819, + "size_bytes": 2896, "created_at": "2026-10-06T00:00:00Z", "source": "environment-pack-author", "satisfies_refs": [], @@ -685,5 +685,5 @@ "description": null } }, - "set_digest": "sha256:8ce57707a4090d7496e5c30883baee473d7db14a498d0d1aeb5e80d881da2582" + "set_digest": "sha256:e64a2fa8054dd15072ac9566526e551c587a3c7493f633417351c45ef71a558c" } diff --git a/pyproject.toml b/pyproject.toml index b1cb1fb..082a7a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "raes-env-packs" # Static version, bumped by release-please on release (ADR 0008). -version = "6.3.0" +version = "6.3.1" description = "RAES environment-pack definition, schemas, template, and authoring/validation tooling." readme = "README.md" # Floor follows the pinned raes runtime dependency, which requires >=3.11 diff --git a/src/raes_env_packs/release.py b/src/raes_env_packs/release.py index 54561b0..c61f502 100644 --- a/src/raes_env_packs/release.py +++ b/src/raes_env_packs/release.py @@ -158,6 +158,22 @@ def _resolved_within(base_real: str, *parts: str) -> str: # -------------------------------------------------------------------------- # Pack contract loading # -------------------------------------------------------------------------- +# yaml.safe_load builds a ``!!set``-tagged mapping node as a Python set. +_YAML_KINDS: tuple[tuple[type[object], str], ...] = ( + (list, "list"), (dict, "mapping"), (set, "set")) + + +def _yaml_kind(value: object) -> str: + """Name the YAML node kind of a parsed value, never the value itself.""" + return next((kind for typ, kind in _YAML_KINDS if isinstance(value, typ)), "scalar") + + +def _list_at(doc: object, key: str) -> list[object]: + """Return the list at ``doc[key]``; any other value reads as empty.""" + value = doc.get(key) if isinstance(doc, dict) else None + return value if isinstance(value, list) else [] + + class PackContracts(object): """The committed contracts the release tool reads, loaded once per pack.""" @@ -165,40 +181,73 @@ def __init__(self, pack_root: str) -> None: """Initialize the instance.""" self.pack_root = os.path.abspath(pack_root) self._root_real = os.path.realpath(self.pack_root) - self.pack_yaml = self._read("pack.yaml") or {} - self.name = self.pack_yaml.get("name") or os.path.basename(self.pack_root) - self.compatibility = self._read_pointer(self.pack_yaml.get("compatibility_manifest")) + missing = object() + manifest = self._read("pack.yaml", default=missing) + # A pack.yaml, compatibility manifest or profiles/bundles.yaml that + # parses to a list or a scalar (an empty or null file included) is + # reported as a failure for this pack and read as absent, instead of + # crashing every caller on ``.get``. An absent or unreadable file stays + # absent: a pack without a compatibility manifest is an explicit skip. + self.name = (manifest if isinstance(manifest, dict) else {}).get( + "name", os.path.basename(self.pack_root)) + self.manifest_failures: list[str] = [] + self.pack_yaml = self._mapping("pack.yaml", manifest, missing) or {} + compat_rel = self.pack_yaml.get("compatibility_manifest") + self.compatibility = self._mapping( + compat_rel, self._read_pointer(compat_rel, default=missing), missing) self.provenance = self._read_pointer(self.pack_yaml.get("provenance_ledger")) self.publication = self._read_pointer(self.pack_yaml.get("publication_supply")) - self.bundles = self._read(os.path.join("profiles", "bundles.yaml")) + bundles_rel = os.path.join("profiles", "bundles.yaml") + self.bundles = self._mapping( + "profiles/bundles.yaml", self._read(bundles_rel, default=missing), missing) + # A value the release gate iterates that is not a list is reported as + # a failure for this pack and read as empty. + for rel, doc, key in ((compat_rel, self.compatibility, "delivery_bundles"), + (compat_rel, self.compatibility, "runtime_profiles"), + ("profiles/bundles.yaml", self.bundles, "bundles")): + value = doc.get(key) if isinstance(doc, dict) else None + if value is not None and not isinstance(value, list): + self.manifest_failures.append( + f"{self.name}: {rel} {key} is a YAML {_yaml_kind(value)}, not a list") + + def _mapping(self, rel: object, value: object, missing: object) -> dict[str, object] | None: + """Return ``value`` when it is a mapping; record a failure unless absent.""" + if isinstance(value, dict): + return value + if value is not missing: + self.manifest_failures.append( + f"{self.name}: {rel} is a YAML {_yaml_kind(value)}, not a mapping") + return None + + def _read(self, rel: str, *, default: object = None) -> object: + """Read contained YAML; return ``default`` only when it cannot be read. - def _read(self, rel: str) -> object: - """Read a pack-relative YAML file, validated at the open() sink.""" + A successfully parsed null remains ``None``, distinct from ``default``. + """ try: path = _resolved_within(self._root_real, rel) if not os.path.isfile(path): - return None + return default with open(path, "r", encoding="utf-8") as fh: return yaml.safe_load(fh) except (ValueError, OSError, yaml.YAMLError): - return None + return default - def _read_pointer(self, rel: object) -> object: - """Read pointer.""" - return self._read(rel) if isinstance(rel, str) else None + def _read_pointer(self, rel: object, *, default: object = None) -> object: + """Read the file a pack.yaml pointer names; ``default`` when it cannot be read.""" + return self._read(rel, default=default) if isinstance(rel, str) else default @property def supported_bundles(self) -> list[dict[str, object]]: """Supported bundles.""" - compat = self.compatibility or {} - return [b for b in (compat.get("delivery_bundles") or []) + return [b for b in _list_at(self.compatibility, "delivery_bundles") if isinstance(b, dict) and b.get("status") == "supported"] @property def manifest_bundle_rows(self) -> dict[str, dict[str, object]]: """Manifest bundle rows.""" - rows = (self.bundles or {}).get("bundles") or [] - return {r.get("id"): r for r in rows if isinstance(r, dict) and r.get("id")} + return {r.get("id"): r for r in _list_at(self.bundles, "bundles") + if isinstance(r, dict) and r.get("id")} def is_releasable(pack_root: str) -> bool: @@ -278,16 +327,6 @@ def _lint_bundle(pc: "PackContracts", pack_root: str, bundle: dict[str, object], return failures -# yaml.safe_load builds a ``!!set``-tagged mapping node as a Python set. -_YAML_KINDS: tuple[tuple[type[object], str], ...] = ( - (list, "list"), (dict, "mapping"), (set, "set")) - - -def _yaml_kind(value: object) -> str: - """Name the YAML node kind of a parsed value, never the value itself.""" - return next((kind for typ, kind in _YAML_KINDS if isinstance(value, typ)), "scalar") - - def _pack_yaml_mapping(pc: PackContracts, key: str) -> tuple[dict[str, object], list[str]]: """Read a ``pack.yaml`` key that the contract defines as a mapping. @@ -332,6 +371,8 @@ def _index_ids(pc: PackContracts) -> tuple[set[object], list[str]]: def lint_pack(pack_root: str) -> list[str]: """Verify a pack ships every delivery bundle it advertises as supported.""" pc = PackContracts(pack_root) + if pc.manifest_failures: + return list(pc.manifest_failures) supported = pc.supported_bundles if not supported: # nothing claimed supported -> nothing to ship @@ -359,9 +400,9 @@ def lint_pack(pack_root: str) -> list[str]: _SAFE_SLUG_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") -def _is_safe_slug(value: str) -> bool: - """Is safe slug.""" - return bool(value) and value not in (".", "..") and \ +def _is_safe_slug(value: object) -> bool: + """Is safe slug (a non-string pack name, such as ``name: 2024``, is not).""" + return isinstance(value, str) and value not in (".", "..") and \ _SAFE_SLUG_RE.fullmatch(value) is not None @@ -458,8 +499,11 @@ def _stage_tree(root_fd: int, pack_root: str, rel: str, dst: str, def _resolve_release_paths(pc: "PackContracts", out_dir: str, version: str) -> tuple[str | None, str | None, list[str]]: """Validate pack-controlled name/version and resolve the staging + release - roots, both contained under the resolved ``out_dir`` (Sonar S8707).""" - failures = [ + roots, both contained under the resolved ``out_dir`` (Sonar S8707). + + A malformed pack manifest fails first: the name, version and views would + only be fallbacks, so nothing is resolved for it.""" + failures = list(pc.manifest_failures) or [ f"{pc.name}: pack {label} {value!r} is not a path-safe slug" for label, value in (("name", pc.name), ("version", version)) if not _is_safe_slug(value)] @@ -604,7 +648,10 @@ def build_release(pack_root: str, out_dir: str, *, tier_stats: dict[str, dict[str, object]] = { tier: {"file_count": 0, "exports": {}} for tier in sorted(set(BOUNDARY_TIERS.values())) } - metadata = release_metadata(pack_root, include_build_provenance=include_build_provenance) + # No profile is derived from a malformed pack manifest; + # _resolve_release_paths reports it before anything is staged. + metadata = {} if pc.manifest_failures else release_metadata( + pack_root, include_build_provenance=include_build_provenance) staged_by_view: dict[str, list[str]] = {} release_root, staging, failures = _resolve_release_paths(pc, out_dir, version) if failures: @@ -849,14 +896,14 @@ def _emit_evidence(pc: "PackContracts", pack_root: str, def _supported_bundle_ids(compat: dict[str, object]) -> list[str]: """Supported bundle ids.""" return sorted( - b.get("bundle_id") for b in (compat.get("delivery_bundles") or []) + b.get("bundle_id") for b in _list_at(compat, "delivery_bundles") if isinstance(b, dict) and b.get("status") == "supported" and b.get("bundle_id")) def _runtime_profile_ids(compat: dict[str, object]) -> list[str]: """Runtime profile ids.""" return sorted( - rp.get("profile_id") for rp in (compat.get("runtime_profiles") or []) + rp.get("profile_id") for rp in _list_at(compat, "runtime_profiles") if isinstance(rp, dict) and rp.get("status") in ("supported", "required") and rp.get("profile_id")) @@ -1279,6 +1326,8 @@ def _smoke_view_leaks(pc: "PackContracts", pack_root: str, def smoke_pack(pack_root: str) -> list[str]: """Smoke-test that profile selection changes participant exposure correctly.""" pc = PackContracts(pack_root) + if pc.manifest_failures: + return list(pc.manifest_failures) supported = pc.supported_bundles if not supported: return [] @@ -1323,24 +1372,30 @@ def check( checked = 0 for pack_root in pack_roots: name = os.path.basename(pack_root) - if not is_releasable(pack_root): + # A malformed pack manifest fails this pack; the remaining packs still run. + malformed = PackContracts(pack_root).manifest_failures + if not malformed and not is_releasable(pack_root): print(f" [skip] {name}: not releasable " "(no compatibility manifest with artifact_boundaries)") continue checked += 1 - before = len(failures) - failures += lint_pack(pack_root) - failures += smoke_pack(pack_root) - with tempfile.TemporaryDirectory() as out: - _meta, build_failures = build_release(pack_root, out) - failures += build_failures - status = "ok" if len(failures) == before else "fail" + pack_failures = malformed or _release_checks(pack_root) + failures += pack_failures + status = "fail" if pack_failures else "ok" print(f" [{status}] {name} release checks") if checked == 0: print(" [warn] no releasable packs found") return failures +def _release_checks(pack_root: str) -> list[str]: + """Lint, smoke and build one releasable pack into a scratch directory.""" + failures = lint_pack(pack_root) + smoke_pack(pack_root) + with tempfile.TemporaryDirectory() as out: + _meta, build_failures = build_release(pack_root, out) + return failures + build_failures + + # -------------------------------------------------------------------------- # CLI # -------------------------------------------------------------------------- @@ -1367,7 +1422,11 @@ def _report(label: str, failures: list[str]) -> int: def _cmd_metadata(args: argparse.Namespace) -> int: """Cmd metadata.""" - print(yaml.safe_dump(release_metadata(_resolve_pack(args.pack)), sort_keys=False)) + pack_root = _resolve_pack(args.pack) + malformed = PackContracts(pack_root).manifest_failures + if malformed: + return _report("PACK METADATA", malformed) + print(yaml.safe_dump(release_metadata(pack_root), sort_keys=False)) return 0 diff --git a/tests/test_distribution_rehearsal.py b/tests/test_distribution_rehearsal.py index 10595ff..c3e3024 100644 --- a/tests/test_distribution_rehearsal.py +++ b/tests/test_distribution_rehearsal.py @@ -1,11 +1,13 @@ -"""Regression coverage for the pack-distribution rehearsal harness (#452). +"""Regression coverage for the pack-distribution rehearsal harness (#452, #451). ``tests_integration/distribution_rehearsal.py`` used to read ``techvault-0.1.0`` -paths, so it stopped working once TechVault's ``pack.yaml`` moved past 0.1.0. -These tests run it at whatever version ``pack.yaml`` declares: the offline -rehearsal through the script's own entry point, and the registry rehearsal -against stub ``oras`` and ``cosign`` executables that keep pushed files in a -local store and serve them back on pull. +paths, so it stopped working once TechVault's ``pack.yaml`` moved past 0.1.0, +and it always rehearsed TechVault whatever pack the workflow's ``pack`` input +named. These tests run it at whatever version ``pack.yaml`` declares, for +TechVault and for the pack ``REHEARSAL_PACK`` names: the offline rehearsal +through the script's own entry point, and the registry rehearsal against stub +``oras`` and ``cosign`` executables that keep pushed files in a local store and +serve them back on pull. """ from __future__ import annotations @@ -26,8 +28,18 @@ ROOT = Path(__file__).resolve().parents[1] REHEARSAL = ROOT / "tests_integration" / "distribution_rehearsal.py" -PACK = yaml.safe_load((ROOT / "packs" / "techvault" / "pack.yaml").read_text(encoding="utf-8")) -STEM = f"{PACK['name']}-{PACK['version']}" +TECHVAULT = ROOT / "packs" / "techvault" +# A second hosted pack, so a rehearsal that ignores REHEARSAL_PACK is caught. +STUDY = ROOT / "packs" / "techvault-participant-study" + + +def _manifest(pack_dir: Path) -> dict: + return yaml.safe_load((pack_dir / "pack.yaml").read_text(encoding="utf-8")) + + +def _stem(pack_dir: Path) -> str: + manifest = _manifest(pack_dir) + return f"{manifest['name']}-{manifest['version']}" # One stub stands in for both tools. It logs every call, and cosign calls just # succeed, as a keyless signature from the GitHub OIDC identity would in CI. @@ -50,6 +62,18 @@ """ +def _rehearse(pack: str | None) -> subprocess.CompletedProcess: + """Run the script as the workflow does, with ``REHEARSAL_PACK`` set or unset.""" + + env = {key: value for key, value in os.environ.items() + if key not in ("REHEARSAL_REGISTRY", "REHEARSAL_PACK")} + if pack is not None: + env["REHEARSAL_PACK"] = pack + return subprocess.run( + [sys.executable, str(REHEARSAL)], cwd=ROOT, env=env, + capture_output=True, text=True, timeout=600) + + def _load_rehearsal(): spec = importlib.util.spec_from_file_location("distribution_rehearsal", REHEARSAL) module = importlib.util.module_from_spec(spec) @@ -61,17 +85,37 @@ def _load_rehearsal(): class DistributionRehearsalTests(unittest.TestCase): def test_offline_rehearsal_passes_at_the_pack_yaml_version(self) -> None: - env = {key: value for key, value in os.environ.items() - if key != "REHEARSAL_REGISTRY"} - completed = subprocess.run( - [sys.executable, str(REHEARSAL)], cwd=ROOT, env=env, - capture_output=True, text=True, timeout=600) + completed = _rehearse(None) self.assertEqual(completed.returncode, 0, completed.stderr or completed.stdout) - self.assertIn(f"[rehearsal] published {STEM} ", completed.stdout) + self.assertIn(f"[rehearsal] published {_stem(TECHVAULT)} ", completed.stdout) self.assertIn("[rehearsal] OFFLINE REHEARSAL PASSED", completed.stdout) - def test_registry_rehearsal_pushes_the_built_release_under_the_pack_version(self) -> None: + def test_offline_rehearsal_rehearses_the_pack_the_dispatch_names(self) -> None: + completed = _rehearse("packs/techvault-participant-study") + + self.assertEqual(completed.returncode, 0, completed.stderr or completed.stdout) + self.assertIn(f"[rehearsal] published {_stem(STUDY)} ", completed.stdout) + self.assertIn("[rehearsal] OFFLINE REHEARSAL PASSED", completed.stdout) + + def test_a_directory_that_is_not_a_pack_stops_before_publishing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + # A pack.yaml whose name is not its directory's name. + mismatched = Path(tmp, "not-techvault") + mismatched.mkdir() + (mismatched / "pack.yaml").write_text( + (TECHVAULT / "pack.yaml").read_text(encoding="utf-8"), encoding="utf-8") + for pack in ("docs", "packs/no-such-pack", str(mismatched)): + with self.subTest(pack=pack): + completed = _rehearse(pack) + + self.assertNotEqual(completed.returncode, 0, completed.stdout) + self.assertIn(f"rehearsal stopped: {pack} (", completed.stderr) + self.assertIn("is not a pack", completed.stderr) + self.assertNotIn("[rehearsal] published", completed.stdout) + + def _assert_registry_rehearsal(self, pack_dir: Path) -> None: + manifest, stem = _manifest(pack_dir), _stem(pack_dir) rehearsal = _load_rehearsal() with tempfile.TemporaryDirectory() as tmp: store, stubs = Path(tmp, "store"), Path(tmp, "bin") @@ -85,19 +129,27 @@ def test_registry_rehearsal_pushes_the_built_release_under_the_pack_version(self "REHEARSAL_STUB_STORE": str(store)} output = io.StringIO() with mock.patch.dict(os.environ, env), contextlib.redirect_stdout(output): - rehearsal.registry_rehearsal("registry.invalid", str(Path(tmp, "work"))) + rehearsal.registry_rehearsal( + "registry.invalid", str(Path(tmp, "work")), str(pack_dir)) calls = [json.loads(line) for line in (store / "calls.jsonl").read_text(encoding="utf-8").splitlines()] push = next(call for call in calls if call[:2] == ["oras", "push"]) - self.assertEqual(push[3], f"registry.invalid/{PACK['name']}:{PACK['version']}") + self.assertEqual(push[3], f"registry.invalid/{manifest['name']}:{manifest['version']}") self.assertEqual( [spec.split(":", 1)[0] for spec in push[4:]], - [f"{STEM}.tar.gz", "release.yaml", f"{STEM}.cdx.json", f"{STEM}.provenance.json"]) + [f"{stem}.tar.gz", "release.yaml", f"{stem}.cdx.json", f"{stem}.provenance.json"]) self.assertIn(["cosign", "sign", "--yes", "--allow-http-registry", - f"registry.invalid/{PACK['name']}@sha256:{'0' * 64}"], calls) + f"registry.invalid/{manifest['name']}@sha256:{'0' * 64}"], calls) + # Passing means the pack-named consumer directories passed static validation. self.assertIn("[rehearsal] REGISTRY REHEARSAL PASSED", output.getvalue()) + def test_registry_rehearsal_pushes_the_built_release_under_the_pack_version(self) -> None: + self._assert_registry_rehearsal(TECHVAULT) + + def test_registry_rehearsal_names_the_repository_and_consumers_after_the_pack(self) -> None: + self._assert_registry_rehearsal(STUDY) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_hosted_pack_byte_binding.py b/tests/test_hosted_pack_byte_binding.py new file mode 100644 index 0000000..6bfb41d --- /dev/null +++ b/tests/test_hosted_pack_byte_binding.py @@ -0,0 +1,24 @@ +"""Every hosted pack's associated-artifact manifest byte-binds its current files.""" + +from __future__ import annotations + +import pathlib +import unittest + +from raes_env_packs import validate_pack_content_manifest + + +_PACKS = pathlib.Path(__file__).resolve().parents[1] / "packs" + + +class HostedPackByteBindingTests(unittest.TestCase): + def test_every_hosted_pack_manifest_byte_binds(self) -> None: + packs = sorted(path.parent for path in _PACKS.glob("*/pack.yaml")) + self.assertTrue(packs, "no hosted packs found under packs/") + for pack in packs: + with self.subTest(pack=pack.name): + validate_pack_content_manifest(pack) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hosted_pack_content_binding.py b/tests/test_hosted_pack_content_binding.py new file mode 100644 index 0000000..c6ae6ca --- /dev/null +++ b/tests/test_hosted_pack_content_binding.py @@ -0,0 +1,42 @@ +"""Byte binding of every hosted pack's associated-artifact manifest (#470). + +Every directory under ``packs/`` is published first-party content (ADR 0036), and +verified install requires its RAES associated-artifact manifest to bind the +pack's exact bytes (ADR 0037). This gate runs the RAES validator (ADR 0012, +ADR 0031) on each one, so a content change that skips re-deriving the manifest +fails the ``tests`` job instead of a later install. +""" + +from __future__ import annotations + +import pathlib +import unittest + +from raes_env_packs import content_ci +from raes_env_packs.digest import validate_pack_content_manifest + +_PACKS = pathlib.Path(__file__).resolve().parents[1] / "packs" + + +def _hosted_packs() -> tuple[str, ...]: + """Return every pack directory with the discovery the hosted-pack gates use.""" + + failures: list[str] = [] + names = content_ci._packs(str(_PACKS), failures, require_root=True) + if failures: + raise AssertionError(f"pack discovery failed under {_PACKS}: {failures}") + return names + + +class HostedPackContentBindingTests(unittest.TestCase): + def test_discovery_finds_hosted_packs(self) -> None: + self.assertIn("ai-escape-lab", _hosted_packs()) + + def test_every_hosted_pack_manifest_binds_its_bytes(self) -> None: + for name in _hosted_packs(): + with self.subTest(pack=name): + validate_pack_content_manifest(_PACKS / name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pack_distribution_workflow.py b/tests/test_pack_distribution_workflow.py index d0fe8de..1a95d12 100644 --- a/tests/test_pack_distribution_workflow.py +++ b/tests/test_pack_distribution_workflow.py @@ -99,6 +99,20 @@ def index(predicate) -> int: self.assertLess(cosign, rehearsal, "cosign must be installed before the rehearsal") self.assertLess(oras, rehearsal, "oras must be installed before the rehearsal") + def test_rehearsal_step_passes_the_pack_input_through_env(self) -> None: + inputs = _triggers(self.data)["workflow_dispatch"]["inputs"] + self.assertEqual(inputs["pack"].get("default"), "packs/techvault") + step = next(s for s in self.steps if "distribution_rehearsal.py" in str(s.get("run", ""))) + self.assertEqual(step.get("env", {}).get("REHEARSAL_PACK"), "${{ inputs.pack }}", + "the rehearsal must receive the pack input through env:") + + def test_no_run_body_interpolates_an_expression(self) -> None: + # GitHub's script-injection guidance: expressions go through env:, so a + # dispatch value is never pasted into the generated shell script. + for step in self.steps: + self.assertNotIn("${{", str(step.get("run", "")), + f"step {step.get('name')!r} interpolates an expression in run:") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_published_kits.py b/tests/test_published_kits.py index 7a8c121..ae59af3 100644 --- a/tests/test_published_kits.py +++ b/tests/test_published_kits.py @@ -14,7 +14,7 @@ ROOT = Path(__file__).resolve().parents[1] -EXPECTED_RELEASE_COUNT = 89 +EXPECTED_RELEASE_COUNT = 90 ISSUE_225_KITS = { "certificate-authority": ( diff --git a/tests/test_release.py b/tests/test_release.py index 7ada026..4fbfe4b 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -23,9 +23,12 @@ from __future__ import annotations import importlib.util +import io +import itertools import os import tempfile import unittest +from contextlib import redirect_stdout from unittest import mock import yaml @@ -713,5 +716,291 @@ def test_check_accepts_an_explicit_pack_path(self): releasable.assert_called_once_with(pack_root) +# -------------------------------------------------------------------------- +# A pack.yaml that is not a mapping (#447) +# -------------------------------------------------------------------------- +# (kind named in the failure, pack.yaml body) +_NON_MAPPING_PACK_YAML = ( + ("list", "- name: broken\n- version: 0.1.0\n"), + ("list", "[]\n"), + ("scalar", "just a string\n"), + ("scalar", "2024\n"), + ("scalar", "null\n"), + ("scalar", "~\n"), + ("scalar", ""), + ("scalar", "false\n"), + ("scalar", "0\n"), +) +_SUBCOMMAND_LABELS = ( + ("check", "PACK RELEASE GATE"), + ("lint", "PACK LINT"), + ("smoke", "PROFILE SMOKE"), + ("metadata", "PACK METADATA"), + ("build", "PACK BUILD"), +) + + +def _write_broken_pack(parent: str, body: str) -> str: + root = os.path.join(parent, "broken") + _write(os.path.join(root, "pack.yaml"), body) + return root + + +def _run_cli(argv: list[str]) -> tuple[int, str]: + out = io.StringIO() + with redirect_stdout(out): + code = PR.main(argv) + return code, out.getvalue() + + +class MalformedPackYamlTests(unittest.TestCase): + """A pack.yaml that is not a mapping is a failure, never a traceback.""" + + def test_missing_manifest_and_absent_name_keep_directory_fallback(self): + for body in (None, "{}\n"): + with self.subTest(body=body), tempfile.TemporaryDirectory() as tmp: + pack = os.path.join(tmp, "broken") + os.makedirs(pack) + if body is not None: + _write(os.path.join(pack, "pack.yaml"), body) + pc = PR.PackContracts(pack) + self.assertEqual(pc.name, "broken") + self.assertEqual(pc.manifest_failures, []) + code, out = _run_cli(["check", "--pack", pack]) + self.assertEqual(code, 0, out) + self.assertIn(" [skip] broken: not releasable", out) + + def test_check_fails_the_pack_and_still_checks_the_packs_after_it(self): + for kind, body in _NON_MAPPING_PACK_YAML: + with self.subTest(body=body), tempfile.TemporaryDirectory() as root: + # "broken" sorts before "synthpack", so the valid pack runs after it. + _write_broken_pack(root, body) + _make_pack(root, delivery_bundles=[]) + code, out = _run_cli(["check", "--packs-root", root]) + self.assertEqual(code, 1, out) + lines = out.splitlines() + self.assertIn(" [fail] broken release checks", lines) + self.assertIn(" [ok] synthpack release checks", lines) + self.assertIn("PACK RELEASE GATE: FAIL (1 issue(s))", lines) + self.assertIn(f" - broken: pack.yaml is a YAML {kind}, not a mapping", lines) + + def test_every_subcommand_reports_the_pack_instead_of_raising(self): + for (kind, body), (cmd, label) in itertools.product( + _NON_MAPPING_PACK_YAML, _SUBCOMMAND_LABELS): + with self.subTest(cmd=cmd, body=body), tempfile.TemporaryDirectory() as tmp: + pack = _write_broken_pack(tmp, body) + out_dir = os.path.join(tmp, "out") + argv = [cmd, "--pack", pack] + (["--out", out_dir] if cmd == "build" else []) + code, out = _run_cli(argv) + self.assertEqual(code, 1, out) + self.assertIn(f"{label}: FAIL (1 issue(s))", out) + self.assertIn(f" - broken: pack.yaml is a YAML {kind}, not a mapping", out) + self.assertFalse(os.path.exists(out_dir), "a failed build wrote output") + + def test_pack_yaml_readers_treat_it_as_absent(self): + for _, body in _NON_MAPPING_PACK_YAML: + with self.subTest(body=body), tempfile.TemporaryDirectory() as tmp: + pack = _write_broken_pack(tmp, body) + self.assertFalse(PR.is_releasable(pack)) + self.assertEqual(PR._manifest_pointer(pack), "") + + def test_build_reports_a_non_string_pack_name_as_unsafe(self): + for value in (2024, True, ["synthpack"], 0, 0.0, False, [], {}, None): + with self.subTest(name=value), tempfile.TemporaryDirectory() as parent, \ + tempfile.TemporaryDirectory() as out: + pack = _make_pack(parent, delivery_bundles=[]) + pack_yaml = os.path.join(pack, "pack.yaml") + with open(pack_yaml, encoding="utf-8") as fh: + manifest = yaml.safe_load(fh) + manifest["name"] = value + _write(pack_yaml, yaml.safe_dump(manifest)) + _meta, failures = PR.build_release(pack, out) + self.assertEqual( + failures, [f"{value}: pack name {value!r} is not a path-safe slug"]) + self.assertEqual(os.listdir(out), [], "a failed build wrote output") + + + +# -------------------------------------------------------------------------- +# A compatibility manifest or profiles/bundles.yaml that is not a mapping (#448) +# -------------------------------------------------------------------------- +_COMPAT = "pack.compatibility.yaml" +_BUNDLES = "profiles/bundles.yaml" +_MALFORMED_FILES = (_COMPAT, _BUNDLES) +# (PackContracts attribute, reader derived from it, its value when absent) +_EMPTY_READERS = { + _COMPAT: ("compatibility", "supported_bundles", []), + _BUNDLES: ("bundles", "manifest_bundle_rows", {}), +} + + +def _write_broken_contract_pack(parent: str, rel: str, body: str) -> str: + """A pack named "broken" whose ``rel`` file parses to ``body``.""" + root = os.path.join(parent, "broken") + _write(os.path.join(root, "pack.yaml"), yaml.safe_dump( + {"name": "broken", "compatibility_manifest": _COMPAT})) + _write(os.path.join(root, rel), body) + return root + + +class MalformedCompatibilityAndBundlesTests(unittest.TestCase): + """A non-mapping compatibility or bundles manifest fails the pack, never skips it.""" + + def test_top_level_and_nested_failures_are_both_reported(self): + for malformed, nested, key in ( + (_COMPAT, _BUNDLES, "bundles"), + (_BUNDLES, _COMPAT, "delivery_bundles")): + with self.subTest(malformed=malformed), tempfile.TemporaryDirectory() as tmp: + pack = _make_supported_pack(tmp) + _write(os.path.join(pack, malformed), "- invalid\n") + _set_list_value(pack, nested, key, 5) + code, out = _run_cli(["check", "--pack", pack]) + self.assertEqual(code, 1, out) + self.assertIn("PACK RELEASE GATE: FAIL (2 issue(s))", out) + self.assertIn( + f" - synthpack: {malformed} is a YAML list, not a mapping", out) + self.assertIn( + f" - synthpack: {nested} {key} is a YAML scalar, not a list", out) + + def test_every_subcommand_reports_the_file(self): + for rel, (kind, body), (cmd, label) in itertools.product( + _MALFORMED_FILES, _NON_MAPPING_PACK_YAML, _SUBCOMMAND_LABELS): + with self.subTest(file=rel, cmd=cmd, body=body), \ + tempfile.TemporaryDirectory() as tmp: + pack = _make_supported_pack(tmp) + _write(os.path.join(pack, rel), body) + out_dir = os.path.join(tmp, "out") + argv = [cmd, "--pack", pack] + (["--out", out_dir] if cmd == "build" else []) + code, out = _run_cli(argv) + self.assertEqual(code, 1, out) + lines = out.splitlines() + self.assertIn(f"{label}: FAIL (1 issue(s))", lines) + self.assertIn(f" - synthpack: {rel} is a YAML {kind}, not a mapping", lines) + self.assertFalse(os.path.exists(out_dir), "a failed build wrote output") + + def test_check_fails_the_pack_and_still_checks_the_packs_after_it(self): + for rel, (kind, body) in itertools.product(_MALFORMED_FILES, _NON_MAPPING_PACK_YAML): + with self.subTest(file=rel, body=body), tempfile.TemporaryDirectory() as root: + # "broken" sorts before "synthpack", so the valid pack runs after it. + _write_broken_contract_pack(root, rel, body) + _make_pack(root, delivery_bundles=[]) + code, out = _run_cli(["check", "--packs-root", root]) + self.assertEqual(code, 1, out) + lines = out.splitlines() + self.assertIn(" [fail] broken release checks", lines) + self.assertIn(" [ok] synthpack release checks", lines) + self.assertIn("PACK RELEASE GATE: FAIL (1 issue(s))", lines) + self.assertIn(f" - broken: {rel} is a YAML {kind}, not a mapping", lines) + + def test_readers_treat_the_file_as_absent(self): + for rel, (_, body) in itertools.product(_MALFORMED_FILES, _NON_MAPPING_PACK_YAML): + with self.subTest(file=rel, body=body), tempfile.TemporaryDirectory() as tmp: + pack = _make_supported_pack(tmp) + _write(os.path.join(pack, rel), body) + attr, reader, empty = _EMPTY_READERS[rel] + pc = PR.PackContracts(pack) + self.assertIsNone(getattr(pc, attr)) + self.assertEqual(getattr(pc, reader), empty) + # Only the compatibility manifest decides releasability. + self.assertEqual(PR.is_releasable(pack), rel == _BUNDLES) + self.assertIsInstance(PR.release_metadata(pack), dict) + + def test_absent_files_stay_explicit_skips(self): + with tempfile.TemporaryDirectory() as tmp: + pack = os.path.join(tmp, "broken") + _write(os.path.join(pack, "pack.yaml"), "name: broken\n") + self.assertEqual(PR.PackContracts(pack).manifest_failures, []) + code, out = _run_cli(["check", "--pack", pack]) + self.assertEqual(code, 0, out) + self.assertIn(" [skip] broken: not releasable", out) + with tempfile.TemporaryDirectory() as tmp: + pack = _make_pack(tmp, delivery_bundles=[]) + _write(os.path.join(pack, _BUNDLES), "{}\n") + self.assertEqual(PR.PackContracts(pack).manifest_failures, []) + self.assertEqual(PR.lint_pack(pack), []) + + +# -------------------------------------------------------------------------- +# A delivery_bundles, runtime_profiles or bundles value that is not a list (#468) +# -------------------------------------------------------------------------- +_COMPAT_REL = "pack.compatibility.yaml" +_BUNDLES_REL = "profiles/bundles.yaml" +_LIST_KEYS = ( + (_COMPAT_REL, "delivery_bundles"), + (_COMPAT_REL, "runtime_profiles"), + (_BUNDLES_REL, "bundles"), +) +# (replacement value, kind named in the failure) +_NON_LIST_VALUES = ( + (5, "scalar"), + ("guided", "scalar"), + (0, "scalar"), + ("", "scalar"), + (False, "scalar"), + ({"guided": {}}, "mapping"), + ({}, "mapping"), +) + + +def _set_list_value(pack: str, rel: str, key: str, value) -> None: + _edit_yaml(os.path.join(pack, rel), lambda d: d.__setitem__(key, value)) + + +class NonListBundleValueTests(unittest.TestCase): + """A bundle or profile value that is not a list is a failure, never a traceback.""" + + def test_every_subcommand_names_the_file_and_key(self): + for (rel, key), (value, kind), (cmd, label) in itertools.product( + _LIST_KEYS, _NON_LIST_VALUES, _SUBCOMMAND_LABELS): + with self.subTest(rel=rel, key=key, value=value, cmd=cmd), \ + tempfile.TemporaryDirectory() as tmp: + pack = _make_supported_pack(tmp) + _set_list_value(pack, rel, key, value) + out_dir = os.path.join(tmp, "out") + argv = [cmd, "--pack", pack] + (["--out", out_dir] if cmd == "build" else []) + code, out = _run_cli(argv) + self.assertEqual(code, 1, out) + self.assertIn(f"{label}: FAIL (1 issue(s))", out) + self.assertIn( + f" - synthpack: {rel} {key} is a YAML {kind}, not a list", out) + self.assertFalse(os.path.exists(out_dir), "a failed build wrote output") + + def test_check_fails_the_pack_and_still_checks_the_others(self): + for rel, key in _LIST_KEYS: + with self.subTest(rel=rel, key=key), tempfile.TemporaryDirectory() as root: + # "broken-pack" sorts before "synthpack", so the valid pack runs after it. + broken = _rename_pack(_make_supported_pack(root), "broken-pack") + _set_list_value(broken, rel, key, 5) + _make_supported_pack(root) + code, out = _run_cli(["check", "--packs-root", root]) + self.assertEqual(code, 1, out) + lines = out.splitlines() + self.assertIn(" [fail] broken-pack release checks", lines) + self.assertIn(" [ok] synthpack release checks", lines) + self.assertIn("PACK RELEASE GATE: FAIL (1 issue(s))", lines) + self.assertIn( + f" - broken-pack: {rel} {key} is a YAML scalar, not a list", lines) + + def test_readers_treat_a_non_list_value_as_empty(self): + for rel, key in _LIST_KEYS: + with self.subTest(rel=rel, key=key), tempfile.TemporaryDirectory() as tmp: + pack = _make_supported_pack(tmp) + _set_list_value(pack, rel, key, 5) + pc = PR.PackContracts(pack) + self.assertEqual(len(pc.manifest_failures), 1) + self.assertIsInstance(pc.supported_bundles, list) + self.assertIsInstance(pc.manifest_bundle_rows, dict) + summary = PR.release_metadata(pack)["summary"] + self.assertIsInstance(summary["supported_profiles"], list) + self.assertIsInstance(summary["runtime_profiles"], list) + + def test_a_null_value_reads_as_absent(self): + for rel, key in _LIST_KEYS: + with self.subTest(rel=rel, key=key), tempfile.TemporaryDirectory() as tmp: + pack = _make_supported_pack(tmp) + _set_list_value(pack, rel, key, None) + self.assertEqual(PR.PackContracts(pack).manifest_failures, []) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_smtp_imap_mailbox_domain.py b/tests/test_smtp_imap_mailbox_domain.py new file mode 100644 index 0000000..f1bb70e --- /dev/null +++ b/tests/test_smtp_imap_mailbox_domain.py @@ -0,0 +1,102 @@ +"""SMTP/IMAP mailbox addresses follow the selected mail domain (#435). + +The 1.0.0 and 1.1.0 releases substitute ``mail_domain`` into the domain name +but hard-code both mailbox addresses to ``environment.test``. The 1.2.0 +release derives each address from ``mail_domain``. This test composes the +release through RAES with the default, the declared variation, and a custom +domain, and checks each composed address and domain reference. + +RAES 6.0.1 already reports ``realization.authority-bound-unavailable`` for +the domain name (OpenRAE/rae#1481). The derived addresses must not add a +planning diagnostic to the set that the 1.1.0 release reports. +""" + +from __future__ import annotations + +import shutil +import tempfile +import unittest +from pathlib import Path + +import yaml +from raes import parse_sdl_file +from raes_processor.compiler import compile_scenario_runtime_model +from raes_processor.planner import plan + +from tests.test_kit_compute_substrate import MANIFEST + + +KIT = Path(__file__).resolve().parents[1] / "kits/infrastructure.smtp-imap-mail-service" +CORRECTED = KIT / "1.2.0" +PREVIOUS = KIT / "1.1.0" + + +def _compose(release: Path, parameters: dict) -> tuple[object, set[str]]: + """Return the composed mail service and its planning diagnostic codes.""" + + version = yaml.safe_load((release / "kit.yaml").read_text(encoding="utf-8"))["version"] + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + shutil.copy2(release / "module.sdl.yaml", root / "module.sdl.yaml") + document = { + "name": "composition", + "imports": [ + { + "source": "local:module.sdl.yaml", + "namespace": "subject", + "version": version, + "parameters": parameters, + } + ], + } + (root / "scenario.sdl.yaml").write_text( + yaml.safe_dump(document, sort_keys=False), encoding="utf-8" + ) + scenario = parse_sdl_file(root / "scenario.sdl.yaml", migration_policy="accept") + model = compile_scenario_runtime_model(scenario, parameters={}) + execution = plan(model, MANIFEST) + codes = {diagnostic.code for diagnostic in (*model.diagnostics, *execution.diagnostics)} + return scenario.nodes["subject.mail"].runtime.mail_services[0], codes + + +def _cases() -> dict[str, tuple[dict, str]]: + cases = yaml.safe_load( + (CORRECTED / "tests/composition.yaml").read_text(encoding="utf-8") + ) + return { + "default": (cases["default"], "environment.test"), + "variation": (cases["variation"], "alternate.test"), + "custom": ({**cases["default"], "mail_domain": "example.test"}, "example.test"), + } + + +class SmtpImapMailboxDomainTests(unittest.TestCase): + def test_mailbox_addresses_follow_the_selected_domain(self) -> None: + for case, (parameters, domain) in _cases().items(): + with self.subTest(case=case): + service, _ = _compose(CORRECTED, parameters) + self.assertEqual( + [(entry.domain_id, entry.name) for entry in service.domains], + [("primary_domain", domain)], + ) + self.assertEqual( + [ + (mailbox.mailbox_id, mailbox.address, mailbox.domain_ref) + for mailbox in service.mailboxes + ], + [ + ("operator_mailbox", f"operator@{domain}", "primary_domain"), + ("service_mailbox", f"service@{domain}", "primary_domain"), + ], + ) + + def test_derived_addresses_add_no_planning_diagnostic(self) -> None: + for case, (parameters, _) in _cases().items(): + with self.subTest(case=case): + _, corrected = _compose(CORRECTED, parameters) + _, previous = _compose(PREVIOUS, parameters) + self.assertLessEqual(corrected, previous) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_integration/distribution_rehearsal.py b/tests_integration/distribution_rehearsal.py index c83d009..8616c65 100644 --- a/tests_integration/distribution_rehearsal.py +++ b/tests_integration/distribution_rehearsal.py @@ -4,7 +4,8 @@ Two rehearsals, one script: * The **offline** rehearsal runs everywhere with no external tooling: it - publishes TechVault, exports a deterministic archive, proves clean + publishes the pack named by ``REHEARSAL_PACK`` (default ``packs/techvault``), + exports a deterministic archive, proves clean reproduction, then installs, verifies, updates, and reads rollback guidance through the archive route. This is the inner-loop confidence check. * The **registry** rehearsal runs only when a real OCI registry is reachable and @@ -34,15 +35,40 @@ sys.path.insert(0, os.path.join(_REPO, "src")) from raes_env_packs import distribution as dist # noqa: E402 -from raes_env_packs import release, verify # noqa: E402 +from raes_env_packs import release, validation, verify # noqa: E402 -_TECHVAULT = os.path.join(_REPO, "packs", "techvault") +_DEFAULT_PACK = "packs/techvault" def _log(message: str) -> None: print(f"[rehearsal] {message}", flush=True) +def _pack_root() -> tuple[str, str]: + """Return the pack directory ``REHEARSAL_PACK`` names, as given and resolved. + + The workflow passes its ``pack`` dispatch input here through ``env:``. An + unset or empty value means the default pack; a relative value is resolved + against the repository root, where the workflow runs. + """ + + requested = os.environ.get("REHEARSAL_PACK") or _DEFAULT_PACK + return requested, os.path.join(_REPO, requested) + + +def _require_pack(requested: str, pack_root: str) -> None: + """Stop before anything is published unless ``pack_root`` is a pack. + + ``validate_pack`` is the one static-validation authority (ADR 0013), so a + directory it rejects never reaches the publish gates (ADR 0037). + """ + + result = validation.validate_pack(pack_root) + if not result.ok: + raise SystemExit(f"rehearsal stopped: {requested} ({pack_root}) is not a pack: " + f"{'; '.join(result.errors)}") + + class _Published(NamedTuple): """Where one publish wrote its release, named from the build's own metadata.""" @@ -53,8 +79,8 @@ class _Published(NamedTuple): evidence_files: tuple[tuple[str, str], ...] -def _publish(workdir: str) -> _Published: - """Publish TechVault, export its archive, and say where both were written. +def _publish(workdir: str, pack_root: str) -> _Published: + """Publish the pack at ``pack_root``, export its archive, and say where both were written. The release directory, archive, and evidence names follow the pack name and version that ``build_release`` recorded in the release metadata, so the @@ -62,7 +88,7 @@ def _publish(workdir: str) -> _Published: """ release_out = os.path.join(workdir, "release") - metadata, failures = release.build_release(_TECHVAULT, release_out, publish=True) + metadata, failures = release.build_release(pack_root, release_out, publish=True) if failures: raise SystemExit(f"publish failed: {failures}") pack = metadata["release"]["pack"] @@ -71,10 +97,10 @@ def _publish(workdir: str) -> _Published: if not os.path.isfile(os.path.join(evidence_dir, "release.yaml")): raise SystemExit(f"publish wrote no release.yaml under {evidence_dir}") archive = os.path.join(workdir, f"{stem}.tar.gz") - first = dist.export_pack_archive(_TECHVAULT, archive) + first = dist.export_pack_archive(pack_root, archive) # Clean reproduction: the same source exports byte-identical bytes. again = os.path.join(workdir, f"{stem}-again.tar.gz") - if dist.export_pack_archive(_TECHVAULT, again) != first: + if dist.export_pack_archive(pack_root, again) != first: raise SystemExit("archive export is not reproducible") _log(f"published {stem} + exported archive {first}") evidence = metadata["evidence"] @@ -87,24 +113,25 @@ def _publish(workdir: str) -> _Published: )) -def offline_rehearsal(workdir: str) -> None: +def offline_rehearsal(workdir: str, pack_root: str) -> None: """Publish -> export -> install -> verify -> update -> rollback, no network.""" - published = _publish(workdir) + published = _publish(workdir, pack_root) evidence_dir, archive = published.evidence_dir, published.archive selector = dist.Selector( repository="local", reference=archive, digest_domain=dist.DIGEST_DOMAIN_ARCHIVE) transport = dist.ArchiveTransport() - target = os.path.join(workdir, "consumer", "techvault") + # Static validation requires an installed pack's directory to carry its name. + target = os.path.join(workdir, "consumer", published.name) install = dist.plan_install( - _TECHVAULT, evidence_dir, target, selector=selector, transport=transport) + pack_root, evidence_dir, target, selector=selector, transport=transport) if not install.applicable: raise SystemExit(f"install plan not applicable: {install.render_human()}") # The offline route carries no signature; accept unsigned explicitly. The # authenticated path is exercised by the registry rehearsal. receipt = dist.apply_install( - install, _TECHVAULT, evidence_dir, target, + install, pack_root, evidence_dir, target, authorized=True, policy=dist.PromotionPolicy(selector=selector, transport=transport, require_signature=False)) @@ -116,10 +143,10 @@ def offline_rehearsal(workdir: str) -> None: _log("installed pack verifies") update = dist.plan_update( - target, _TECHVAULT, evidence_dir, selector=selector, transport=transport) + target, pack_root, evidence_dir, selector=selector, transport=transport) _log(f"update plan changes: {[c.category for c in update.changes]}") dist.apply_install( - update, _TECHVAULT, evidence_dir, target, + update, pack_root, evidence_dir, target, authorized=True, policy=dist.PromotionPolicy(selector=selector, transport=transport, require_signature=False)) @@ -136,10 +163,10 @@ def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: return subprocess.run(cmd, check=True, text=True, **kwargs) -def registry_rehearsal(registry: str, workdir: str) -> None: +def registry_rehearsal(registry: str, workdir: str, pack_root: str) -> None: """Push -> keyless-sign -> pull -> verify-signature -> install, over a registry.""" - published = _publish(workdir) + published = _publish(workdir, pack_root) archive = published.archive repo = f"{registry}/{published.name}" ref = f"{repo}:{published.version}" @@ -199,7 +226,7 @@ def cosign_verifier(subject_set_digest: str, provenance: dict) -> bool: # Stage the pulled archive into a clean, pack-named consumer directory and # verify those staged bytes against the pulled evidence (release.yaml + SBOM + # provenance now live under `pulled/`). - staged = os.path.join(workdir, "consumer", "techvault") + staged = os.path.join(workdir, "consumer", published.name) os.makedirs(os.path.dirname(staged), exist_ok=True) dist.stage_pack_archive(pulled_archive, staged) profile, sbom_doc, prov_doc = verify.load_release_evidence(pulled) @@ -215,7 +242,7 @@ def cosign_verifier(subject_set_digest: str, provenance: dict) -> bool: selector = dist.Selector( repository=repo, reference=pulled_archive, digest_domain=dist.DIGEST_DOMAIN_ARCHIVE) transport = dist.ArchiveTransport() - target = os.path.join(workdir, "installed", "techvault") + target = os.path.join(workdir, "installed", published.name) plan = dist.plan_install( staged, pulled, target, selector=selector, transport=transport, signature_verifier=cosign_verifier) @@ -228,12 +255,14 @@ def cosign_verifier(subject_set_digest: str, provenance: dict) -> bool: def main() -> int: + requested, pack_root = _pack_root() + _require_pack(requested, pack_root) with tempfile.TemporaryDirectory() as workdir: - offline_rehearsal(os.path.join(workdir, "offline")) + offline_rehearsal(os.path.join(workdir, "offline"), pack_root) registry = os.environ.get("REHEARSAL_REGISTRY") have_tools = shutil.which("oras") and shutil.which("cosign") if registry and have_tools: - registry_rehearsal(registry, os.path.join(workdir, "registry")) + registry_rehearsal(registry, os.path.join(workdir, "registry"), pack_root) else: _log("registry rehearsal skipped (set REHEARSAL_REGISTRY + install oras/cosign)") return 0