From 5f769c0f394b475ea57d896cef928566ff3c9493 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 31 Jul 2026 07:15:17 +0200 Subject: [PATCH 1/3] feat(cyborg): implement RAES scenario projection --- README.md | 30 +- ...g-cage2-provisioner-manifest-guardrails.md | 141 ++++ ...-cage2-runtime-qualification-guardrails.md | 26 +- .../cyborg-cage2-source-ledger-guardrails.md | 8 +- docs/index.md | 7 +- mkdocs.yml | 3 +- pyproject.toml | 10 +- src/raes_adapters/cyborg/__init__.py | 60 +- src/raes_adapters/cyborg/driver.py | 264 +++++++ src/raes_adapters/cyborg/manifest.py | 246 ++++++ src/raes_adapters/cyborg/mapping/README.md | 5 +- .../cyborg/mapping/cage2-loss-disclosures.md | 3 +- src/raes_adapters/cyborg/provisioner.py | 361 +++++++++ src/raes_adapters/cyborg/qualification.json | 20 +- src/raes_adapters/cyborg/scenario.py | 445 +++++++++++ src/raes_adapters/cyborg/target.py | 154 ++++ tests/test_cyborg_provisioner.py | 745 ++++++++++++++++++ tests/test_cyborg_qualification.py | 36 +- tests/test_cyborg_qualification_driver.py | 38 + tests/test_cyborg_smoke.py | 7 +- tools/__init__.py | 1 + tools/verify_cyborg_qualification.py | 162 ++++ 22 files changed, 2720 insertions(+), 52 deletions(-) create mode 100644 docs/decisions/cyborg-cage2-provisioner-manifest-guardrails.md create mode 100644 src/raes_adapters/cyborg/driver.py create mode 100644 src/raes_adapters/cyborg/manifest.py create mode 100644 src/raes_adapters/cyborg/provisioner.py create mode 100644 src/raes_adapters/cyborg/scenario.py create mode 100644 src/raes_adapters/cyborg/target.py create mode 100644 tests/test_cyborg_provisioner.py create mode 100644 tools/__init__.py diff --git a/README.md b/README.md index c284dd3..41dcdce 100644 --- a/README.md +++ b/README.md @@ -30,14 +30,16 @@ and never define the shared semantic boundary (ADR-002). pip install raes-adapters # shared base plumbing + qualification evidence ``` -The `cyborg` extra key is reserved but intentionally empty. Issue +The selected CybORG backend is admitted and usable through its documented +source installation. The `cyborg` extra key is reserved but currently empty. Issue [#12](https://github.com/RAESystem/adapters/issues/12) qualified the official CAGE Challenge 2 source and a packaging-only fix, but the upstream wheel omits the version and Scenario2 runtime data. The fixed wheel passed a clean Python -3.12 smoke locally, but it is not a governed public artifact and the -distribution's declared Python/platform range is not yet qualified. Advertising -an editable checkout, install-time clone, or unpublished wheel as the extra -would hide those blockers. +3.12 smoke. Until that fix is published, install the pinned CAGE-2 source at +commit `26ce1c1253fa9e2e73f25e6a7f2da32860c11257`, apply +`src/raes_adapters/cyborg/cage2-wheel-package-data.patch`, and install its +`CybORG/` package into the environment. The adapter validates the installed +version and selected source-file digests before constructing the backend. There is also intentionally no `cyberbattlesim` extra. The [qualification record](src/raes_adapters/cyberbattlesim/qualification.json) @@ -168,11 +170,23 @@ and sanitized red/blue/green smoke result. The accompanying [packaging patch](src/raes_adapters/cyborg/cage2-wheel-package-data.patch) is qualification evidence only; it is not silently applied or published. +Issue [#15](https://github.com/RAESystem/adapters/issues/15) supplies a +provisioning-only RAES target for that backend. `create_cyborg_target()` accepts +admitted RAES provisioning plans and deterministically generates the native +CybORG scenario: RAES switches become subnets, VM multiplicity becomes hosts, +infrastructure links become subnet membership, and supported OS families select +digest-verified CybORG images. The portable compiled plan entries and +configuration-bound realization-envelope identity remain in the RAES snapshot; +native CybORG objects stay private. Unsupported or lossy node facts fail before +construction. Action stepping, participant-relative observations, and evaluation +are added by the following roadmap issues rather than being claimed by this +first manifest. + The separate [architecture guardrails](docs/decisions/cyborg-cage2-runtime-qualification-guardrails.md) -define the boundary: the result is not an adapter manifest, conformance claim, -replication claim, or permission to place native CybORG state in portable RAES -artifacts. +define the qualification boundary: source installation and known losses limit +strong replay/equivalence claims, but do not veto adapter construction or +permission to retain an honest partial reproducibility record. ## Development diff --git a/docs/decisions/cyborg-cage2-provisioner-manifest-guardrails.md b/docs/decisions/cyborg-cage2-provisioner-manifest-guardrails.md new file mode 100644 index 0000000..34668db --- /dev/null +++ b/docs/decisions/cyborg-cage2-provisioner-manifest-guardrails.md @@ -0,0 +1,141 @@ +# CybORG/CAGE-2 Provisioner and manifest guardrails + +GitHub issue #15 owns the first usable RAES boundary around the maintainer- +selected CybORG backend. The purpose is incremental: a researcher who uses +CybORG through RAES should retain more portable information about the scenario +and backend selection than a direct invocation retains, even before later +issues add action, observation, evaluation, and equivalence evidence. + +## Authority and admission + +The maintainer selected the pinned CAGE-2/CybORG profile, so the backend is +admitted. Qualification records source identity, installation mechanics, +known defects, and attainable claim strength. It does not decide whether an +adapter may exist. + +The current packaging fact is narrower: the official CybORG 2.1 wheel omits +runtime data, while the qualified packaging-only fix has not been published as +an index artifact. Users can and do install CybORG from source. The adapter +therefore supports that documented source-install route and validates the +installed version and selected runtime-file digests. The empty optional extra +discloses an automatic-installation limitation; it is not an admission veto. + +## Backend boundary + +CybORG is the simulator backend. A RAES `RuntimeTarget` is only the RAES object +that groups the backend adapter's components and manifest. Issue #15 supplies +the Provisioner component: + +- an admitted `ProvisioningPlan` enters the backend adapter; +- the adapter projects the complete desired state into a generated CybORG + scenario and privately owns the resulting simulator; +- the RAES snapshot retains copied portable plan entries and the selected + realization-envelope identity; and +- no native CybORG object or output crosses the portable boundary. + +Issues #16–18 add logical stepping/action translation, participant episodes and +observations, and reward/evaluation projection. The issue #15 manifest must not +claim those components early. + +## Honest partial reproducibility + +The first useful record is intentionally modest. It identifies the admitted +RAES scenario descriptor, selected CybORG source profile, backend manifest, and +realization envelope. That is already more reproducible than an unrecorded +direct constructor call. + +The native Scenario2 file is qualification evidence, not the adapter's runtime +scenario. Construction is a deterministic projection of admitted RAES +resources: + +- each `network` compiled from a RAES switch becomes one CybORG subnet; +- each `node` compiled from a RAES VM becomes one or more CybORG hosts + according to its admitted `count`; +- RAES infrastructure links become CybORG subnet memberships; and +- Linux and Windows OS-family requests select the pinned + `linux_decoy_host` and `windows_user_host1` image definitions. + +The projection uses the complete desired resource set, never a fixed native +scenario selected behind one descriptor. Two different admitted topologies +therefore produce different native scenario documents. Count expansion is +bounded normalization (`host` becomes `host-0`, `host-1`, ... when count is +greater than one). CybORG 2.1 allocates private CIDRs itself, so authored +network addresses are descriptor-substituted rather than falsely reported as +exact. Image selection is an explicit default/image substitution. + +The realization envelope binds those transformations, the selected source +profile, the mapping version, and the configured seed into its configuration +digest. It marks resource allocation, content, accounts, feature bindings, +services, and ACL realization unsupported. +Requests for explicit images, OS versions, resource allocations, asset values, +runtime state, services, features, accounts, ACLs, or unattached hosts reject +before native construction rather than being silently dropped. Constructor +success is not action, observation, replay, scoring, or outcome-equivalence +evidence. + +## Configuration and source checks + +Target configuration is closed. The adapter validates the selected profile id, +source commit, simulator version, mapping-ledger resource, bounded seed, +realization-envelope identity, compiled resource shapes, link closure, native +name uniqueness, and supported semantic subset before native construction. + +The default driver imports only the fixed `CybORG` package surface. It does not +accept an import path, caller-supplied native scenario path, +environment-selected revision, runtime download, subprocess, or secret. Before +import it verifies the complete Python source tree and every selected runtime +file—including the image catalogue and selected images—against qualification +digests. It then copies the package into a private runtime snapshot while +excluding installed bytecode and native import artifacts, verifies the copied +source and selected data again, and imports from that snapshot. Python therefore +executes the bytes whose identity was checked rather than an unverified cache. +The driver writes the generated scenario as a private canonical JSON document +accepted by CybORG's YAML loader, constructs synchronously, and deletes the +document immediately afterward. + +CybORG 2.1 uses Python's process-global random generator while building native +subnets and addresses. Construction therefore runs under a backend-local lock, +seeds before the constructor when configured, repeats the seed before reset, +and restores the caller's global random state. An unseeded target is explicitly +identified as unseeded in the realization envelope. + +## Atomic ownership and cleanup + +CybORG is one aggregate in-process backend, not one native object per RAES plan +operation. The Provisioner interprets the complete desired state and performs +one prepare/replace transition under a lock. + +- Validation completes before construction. +- `UNCHANGED` does not create another backend. +- A failed construction leaves the baseline snapshot unchanged. +- Replacement publishes the candidate only after old-state cleanup succeeds. +- A candidate whose compensation fails remains privately owned for retry. +- A failed old-state cleanup marks that backend unavailable; an unchanged apply + cannot report it healthy and instead must complete cleanup and reconstruct the + desired state. +- Mixed deletes and updates rebuild from the complete desired state; removed + resources cannot survive through a stale aggregate descriptor. +- Delete removes the aggregate backend only when the desired state is empty. +- Repeated cleanup is harmless and retries previously unconfirmed cleanup. + +Portable snapshots, results, receipts, diagnostics, stores, and logs may +contain only published RAES values and bounded adapter messages. Native +handles, ids, tuples, arrays, object representations, paths, logs, exception +text, and tracebacks remain process-private. + +## Manifest evidence + +The backend uses published RAES `BackendManifest`, +`BackendRealizationEnvelopeModel`, `ProvisionerCapabilities`, +`RealizationSupportDeclaration`, and `ConceptBinding` types. It publishes +through `backend_manifest_v2_model`/`backend_manifest_payload`; there is no +parallel schema or serializer. + +The manifest is provisioning-only and names only contract versions exercised +by the target/control-plane path. Tests validate the rendered +`backend-manifest-v2`, SDL-to-plan-to-native differential projection, count and +link preservation, every configuration rejection, fail-closed lossy facts, +seed-bound identity, temporary-document deletion, snapshot portability, +construction/cleanup lifecycle, failed compensation, and source-installed +default-driver smoke. Stronger claims arrive only with their executable +evidence. diff --git a/docs/decisions/cyborg-cage2-runtime-qualification-guardrails.md b/docs/decisions/cyborg-cage2-runtime-qualification-guardrails.md index 6d9d244..8f1880a 100644 --- a/docs/decisions/cyborg-cage2-runtime-qualification-guardrails.md +++ b/docs/decisions/cyborg-cage2-runtime-qualification-guardrails.md @@ -1,10 +1,16 @@ -# CybORG/CAGE-2 runtime qualification guardrails +# CybORG/CAGE-2 backend qualification guardrails Issue #12 is the authority for the qualification outcome. This note fixes the repository and contract boundaries that outcome must respect; it does not select an upstream revision, define a runtime-profile schema, or describe an implementation plan. +Maintainer selection admits the CybORG backend. Qualification grades the +selected source, installation path, controls, known losses, and attainable +reproduction claims; it does not veto adapter implementation. A source-installed +backend can be supported while automatic installation through the optional +extra remains unavailable. + ## Keep the source closure coherent The CAGE Challenge 2 repository contains a bundled `CybORG/` source tree, @@ -64,10 +70,12 @@ root license is not by itself evidence that every nested or separately sourced asset can be redistributed. Dependency licenses and notice obligations remain part of the resolved profile. -The `cyborg` extra must be installable from the published `raes-adapters` -artifact on the declared Python/platform boundary. A successful editable -checkout, local path dependency, floating branch or tag, install-time clone, or -warm user cache is not that proof. If official source cannot produce a +If the `cyborg` extra is populated and advertised as an automatic installation +route, it must be installable from the published `raes-adapters` artifact on the +declared Python/platform boundary. A successful editable checkout, local path +dependency, floating branch or tag, install-time clone, or warm user cache is +not proof of that packaging claim. Keeping the extra empty does not prohibit +the documented source-install route. If official source cannot produce a publishable immutable dependency, any governed repackaging or maintained fork must preserve notices and bind: @@ -119,10 +127,10 @@ the portability claim of the RAES contracts themselves. | Durable state | RAES `ControlPlaneStore` | Qualification uses checked-in immutable evidence and explicit ephemeral temporary directories; it introduces no database, cache authority, or evidence repository. | | Workflow gates | the `policy`, `typecheck`, `tests`, `distributions`, `docs`, and `verify` nox sessions | Keep one canonical graph. Any new CI job would also have to join the `PR Gate` `needs` contract, so qualification belongs in the existing sessions unless isolation cannot otherwise be achieved. | -The release workflow already installs the public -`raes-adapters[cyborg]` artifact on Python 3.12. It remains the public-index -check; source qualification and clean built-wheel smoke belong in the canonical -nox graph, not a second release path. +The release workflow installs the public `raes-adapters[cyborg]` artifact on +Python 3.12. While the extra is empty, that is a distribution-isolation check, +not a claim that it installs CybORG. Source qualification and clean built-wheel +smoke belong in the canonical nox graph, not a second release path. ## Native smoke and observability boundary diff --git a/docs/decisions/cyborg-cage2-source-ledger-guardrails.md b/docs/decisions/cyborg-cage2-source-ledger-guardrails.md index 30c8fa0..e2d2309 100644 --- a/docs/decisions/cyborg-cage2-source-ledger-guardrails.md +++ b/docs/decisions/cyborg-cage2-source-ledger-guardrails.md @@ -206,9 +206,9 @@ contract catalog. Any affected mapped fact must retain a source row and an appropriately tiered loss. - Do not let a mapped row, a complete ledger, a green validator, or a source - digest upgrade the qualification's `not-admissible` decision or imply an - installable extra, backend support, deterministic replay, or outcome - equivalence. + digest imply an installable extra, implemented backend capability, + deterministic replay, or outcome equivalence. Backend admission comes from + maintainer selection; each stronger claim still requires its own evidence. - Do not put semantic values into free-form metadata, `mapping_rule`, `verification`, comments, logs, or Markdown because the intended RAES target is inconvenient. @@ -226,5 +226,5 @@ contract catalog. - No native simulator execution, hidden-state capture, raw log retention, environment scan, privileged host operation, or runtime network service is added for ledger validation. -- No existing qualification blocker, known defect, legal conclusion, source +- No existing qualification limitation, known defect, legal conclusion, source revision, or equivalence claim is changed merely to make the mapping easier. diff --git a/docs/index.md b/docs/index.md index 1633c33..efb2f9e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,8 +5,8 @@ concrete simulator backends to published RAES contracts. It ships shared base plumbing plus backend modules whose dependencies can live behind separate extras in one lock once qualified. The reserved `cyborg` extra remains empty because the selected upstream source needs an unpublished packaging fix. -Backend-local qualification evidence ships fail-closed without making the -native simulator a base dependency. +The maintainer-admitted backend is supported through its documented source +installation without making the native simulator a base dependency. The shared `raes_adapters.base` module provides plumbing only. RAES remains the semantic and protocol authority. @@ -16,8 +16,9 @@ semantic and protocol authority. - [Repository overview](https://github.com/RAESystem/adapters#readme) - [Contribution guide](https://github.com/RAESystem/adapters/blob/dev/CONTRIBUTING.md) - [Architecture decisions](decisions/adrs/README.md) -- [CybORG/CAGE-2 runtime qualification guardrails](decisions/cyborg-cage2-runtime-qualification-guardrails.md) +- [CybORG/CAGE-2 backend qualification guardrails](decisions/cyborg-cage2-runtime-qualification-guardrails.md) - [CybORG/CAGE-2 source-ledger guardrails](decisions/cyborg-cage2-source-ledger-guardrails.md) +- [CybORG/CAGE-2 provisioner and backend-manifest guardrails](decisions/cyborg-cage2-provisioner-manifest-guardrails.md) - [CyberBattleSim qualification guardrails](decisions/cyberbattlesim-qualification-guardrails.md) - [CyberBattleSim scenario and source-ledger guardrails](decisions/cyberbattlesim-scenario-ledger-guardrails.md) - [Project services](maintainers/project-services.md) diff --git a/mkdocs.yml b/mkdocs.yml index c280154..9ae7073 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,8 +37,9 @@ nav: - Overview: decisions/adrs/README.md - Template: decisions/adrs/TEMPLATE.md - Shared simulator-adapter plumbing guardrails: decisions/base-plumbing-guardrails.md - - CybORG/CAGE-2 runtime qualification guardrails: decisions/cyborg-cage2-runtime-qualification-guardrails.md + - CybORG/CAGE-2 backend qualification guardrails: decisions/cyborg-cage2-runtime-qualification-guardrails.md - CybORG/CAGE-2 source-ledger guardrails: decisions/cyborg-cage2-source-ledger-guardrails.md + - CybORG/CAGE-2 provisioner and manifest guardrails: decisions/cyborg-cage2-provisioner-manifest-guardrails.md - CyberBattleSim qualification guardrails: decisions/cyberbattlesim-qualification-guardrails.md - CyberBattleSim scenario and source-ledger guardrails: decisions/cyberbattlesim-scenario-ledger-guardrails.md - ADR 003 — Single distribution and Trusted Publishing: decisions/adrs/adr-003-single-distribution-and-trusted-publishing.md diff --git a/pyproject.toml b/pyproject.toml index 7eb54f5..f7d8e60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,11 +36,10 @@ classifiers = [ dependencies = ["raes==2.0.0"] [project.optional-dependencies] -# Reserved CybORG backend key. Issue #12 found the official CAGE-2 source -# runnable only after a packaging-data patch that has no governed public -# artifact. Keep the extra empty rather than publish an editable checkout, -# install-time clone, or ungoverned wheel; the backend-local qualification -# record carries the exact source, patch, dependency, and smoke evidence. +# Reserved CybORG backend key. The selected backend is supported through the +# documented source-install route. Issue #12 found the official wheel needs a +# packaging-data patch that is not yet published, so the extra remains empty +# instead of hiding that installation limitation behind a runtime clone. cyborg = [] [project.urls] @@ -99,6 +98,7 @@ mypy_path = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-q" +pythonpath = ["."] [tool.coverage.run] branch = true diff --git a/src/raes_adapters/cyborg/__init__.py b/src/raes_adapters/cyborg/__init__.py index d2d7bd4..67407f5 100644 --- a/src/raes_adapters/cyborg/__init__.py +++ b/src/raes_adapters/cyborg/__init__.py @@ -1,13 +1,12 @@ -"""CybORG backend-local qualification evidence. +"""Selected CybORG/CAGE-2 backend adapter and source evidence. Issue #12 binds one immutable CAGE-2 source closure and its qualification -outcome here. The record and packaging-only patch are evidence; they do not -implement an adapter, backend manifest, conformance profile, RAES semantic -model, or native-state serializer. +outcome here. Issue #15 adds the RAES Provisioner, conservative backend +manifest, and target construction for that selected backend. -The native simulator is deliberately not imported by this module. The current -qualification is fail-closed because no governed public artifact contains the -required packaging fix, so base-only installations remain independent. +The native backend is deliberately imported only when the default construction +driver is used. Base-only installations therefore remain independent, while a +user-installed selected CybORG source checkout can be driven through RAES. """ from __future__ import annotations @@ -16,8 +15,6 @@ from importlib.resources import files from typing import Any, cast -__all__ = ["load_qualification", "read_compatibility_patch"] - def load_qualification() -> dict[str, Any]: """Load a fresh copy of the selected CAGE-2 qualification record.""" @@ -29,3 +26,48 @@ def read_compatibility_patch() -> str: """Read the qualification-only packaging patch verbatim.""" resource = files(__package__).joinpath("cage2-wheel-package-data.patch") return resource.read_text(encoding="utf-8") + + +from .driver import ( # noqa: E402 + CyborgDriver, + SourceInstalledCyborgDriver, +) +from .manifest import ( # noqa: E402 + CYBORG_BACKEND_NAME, + CYBORG_PROFILE_ID, + create_cyborg_manifest, + create_cyborg_realization_envelope, +) +from .provisioner import CyborgProvisioner # noqa: E402 +from .scenario import ( # noqa: E402 + CYBORG_SCENARIO_MAPPING_VERSION, + CyborgScenarioDescriptor, + CyborgScenarioResource, + translate_scenario, +) +from .source_ledger import CAGE2_SOURCE_26CE1C1 # noqa: E402 +from .target import ( # noqa: E402 + create_cyborg_components, + create_cyborg_target, + register_cyborg_backend, +) + +__all__ = [ + "CAGE2_SOURCE_26CE1C1", + "CYBORG_BACKEND_NAME", + "CYBORG_PROFILE_ID", + "CYBORG_SCENARIO_MAPPING_VERSION", + "CyborgDriver", + "CyborgProvisioner", + "CyborgScenarioDescriptor", + "CyborgScenarioResource", + "SourceInstalledCyborgDriver", + "create_cyborg_components", + "create_cyborg_manifest", + "create_cyborg_realization_envelope", + "create_cyborg_target", + "load_qualification", + "read_compatibility_patch", + "register_cyborg_backend", + "translate_scenario", +] diff --git a/src/raes_adapters/cyborg/driver.py b/src/raes_adapters/cyborg/driver.py new file mode 100644 index 0000000..01faef7 --- /dev/null +++ b/src/raes_adapters/cyborg/driver.py @@ -0,0 +1,264 @@ +"""Private in-process construction driver for the selected CybORG backend.""" + +from __future__ import annotations + +import hashlib +import json +import os +import random +import shutil +import sys +import tempfile +import threading +from contextlib import suppress +from importlib import import_module, invalidate_caches +from importlib.machinery import PathFinder +from pathlib import Path +from typing import Any, Protocol, cast + +from . import load_qualification +from .scenario import CyborgScenarioDescriptor, translate_scenario + +_NATIVE_RANDOM_LOCK = threading.Lock() + + +class CyborgDriver(Protocol): + """Backend-local driver seam; native handles never cross this protocol.""" + + def construct( + self, + descriptor: CyborgScenarioDescriptor, + *, + seed: int | None, + ) -> object: + """Construct and return one private native CybORG backend handle.""" + + def cleanup(self, handle: object) -> bool: + """Try to release one owned native handle idempotently.""" + + +class SourceInstalledCyborgDriver: + """Construct a generated scenario with a user-installed selected CybORG source.""" + + def __init__(self, *, expected_version: str) -> None: + self._expected_version = expected_version + self._binding_lock = threading.Lock() + self._runtime_workspace: tempfile.TemporaryDirectory[str] | None = None + self._cyborg_type: Any = None + + def construct( + self, + descriptor: CyborgScenarioDescriptor, + *, + seed: int | None, + ) -> object: + """Construct the selected backend while keeping native values private.""" + + cyborg_type = self._native_binding() + scenario = translate_scenario(descriptor) + native: Any = None + scenario_path: Path | None = None + try: + scenario_path = self._write_scenario(scenario) + with _NATIVE_RANDOM_LOCK: + random_state = random.getstate() + try: + if seed is not None: + random.seed(seed) + native = cyborg_type(str(scenario_path), "sim") + if seed is not None: + native.set_seed(seed) + native.reset() + finally: + random.setstate(random_state) + except Exception: + if native is not None: + self.cleanup(native) + raise RuntimeError("CybORG backend construction failed.") from None + finally: + if scenario_path is not None: + scenario_path.unlink(missing_ok=True) + return native + + @staticmethod + def cleanup(handle: object) -> bool: + """Shut down an owned CybORG backend without inspecting native output.""" + + try: + cast(Any, handle).shutdown() + except Exception: + return False + return True + + def _native_binding(self) -> Any: + """Load the constructor from a private snapshot of verified source bytes.""" + + with self._binding_lock: + if self._cyborg_type is not None: + return self._cyborg_type + package_root = self._resolve_verified_package_root() + workspace = tempfile.TemporaryDirectory(prefix="raes-cyborg-runtime-") + verified_root = Path(workspace.name) / "CybORG" + try: + shutil.copytree( + package_root, + verified_root, + ignore=shutil.ignore_patterns( + "__pycache__", + "*.pyc", + "*.pyo", + "*.so", + "*.pyd", + "*.dll", + "*.dylib", + ), + ) + self._verify_selected_files(verified_root) + self._verify_python_source_tree(verified_root) + package = self._import_verified_snapshot(verified_root) + version = str(package.CYBORG_VERSION) + package_file = Path(str(package.__file__)).resolve() + if package_file.parent != verified_root: + raise ValueError + if version != self._expected_version: + raise RuntimeError( + "The installed CybORG backend version does not match the selected profile." + ) + cyborg_type = package.CybORG + except RuntimeError: + workspace.cleanup() + raise + except Exception: + workspace.cleanup() + raise RuntimeError( + "The selected CybORG backend is not installed through its documented " + "source route." + ) from None + self._runtime_workspace = workspace + self._cyborg_type = cyborg_type + return cyborg_type + + @staticmethod + def _import_verified_snapshot(package_root: Path) -> Any: + """Import only from the private source snapshot, never installed bytecode.""" + + if any(name == "CybORG" or name.startswith("CybORG.") for name in sys.modules): + raise RuntimeError( + "CybORG was imported before the selected source snapshot was verified." + ) + parent = str(package_root.parent) + sys.path.insert(0, parent) + invalidate_caches() + try: + package = cast(Any, import_module("CybORG")) + finally: + with suppress(ValueError): + sys.path.remove(parent) + invalidate_caches() + return package + + @staticmethod + def _write_scenario(scenario: dict[str, dict[str, object]]) -> Path: + """Write one private canonical JSON document accepted by CybORG's YAML loader.""" + + descriptor, raw_path = tempfile.mkstemp(prefix="raes-cyborg-", suffix=".json") + path = Path(raw_path) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump( + scenario, + stream, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ) + stream.write("\n") + except Exception: + path.unlink(missing_ok=True) + raise + return path + + @classmethod + def _resolve_verified_package_root(cls) -> Path: + """Resolve and verify package bytes without executing CybORG code.""" + + try: + spec = PathFinder.find_spec("CybORG") + if spec is None or spec.origin is None or spec.submodule_search_locations is None: + raise ValueError + package_file = Path(spec.origin).resolve() + package_root = package_file.parent + search_locations = { + Path(location).resolve() for location in spec.submodule_search_locations + } + if ( + package_file.name != "__init__.py" + or search_locations != {package_root} + or not package_file.is_file() + ): + raise ValueError + except Exception: + raise RuntimeError( + "The selected CybORG backend is not installed through its documented source route." + ) from None + cls._verify_selected_files(package_root) + cls._verify_python_source_tree(package_root) + return package_root + + @staticmethod + def _verify_selected_files(package_root: Path) -> None: + """Prove the installed runtime files match the maintainer-selected source.""" + + prefix = "CybORG/CybORG/" + selected = [ + item + for item in load_qualification()["selected_files"] + if str(item["path"]).startswith(prefix) + ] + for item in selected: + relative = Path(str(item["path"]).removeprefix(prefix)) + candidate = (package_root / relative).resolve() + valid = ( + not relative.is_absolute() + and candidate.is_relative_to(package_root) + and candidate.is_file() + ) + if not valid or hashlib.sha256(candidate.read_bytes()).hexdigest() != item["sha256"]: + raise RuntimeError( + "The installed CybORG backend source does not match the selected profile." + ) + + @staticmethod + def _verify_python_source_tree(package_root: Path) -> None: + """Verify every importable CybORG source file before package execution.""" + + try: + integrity = load_qualification()["runtime"]["integrity"] + expected_count = int(integrity["python_source_count"]) + expected_digest = str(integrity["python_source_tree_sha256"]) + files = sorted( + package_root.rglob("*.py"), + key=lambda path: path.relative_to(package_root).as_posix(), + ) + digest = hashlib.sha256() + for path in files: + resolved = path.resolve() + if not resolved.is_relative_to(package_root) or not resolved.is_file(): + raise ValueError + relative = path.relative_to(package_root).as_posix() + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + digest.update(hashlib.sha256(resolved.read_bytes()).digest()) + valid = len(files) == expected_count and digest.hexdigest() == expected_digest + except Exception: + valid = False + if not valid: + raise RuntimeError( + "The installed CybORG backend source does not match the selected profile." + ) + + +__all__ = [ + "CyborgDriver", + "SourceInstalledCyborgDriver", +] diff --git a/src/raes_adapters/cyborg/manifest.py b/src/raes_adapters/cyborg/manifest.py new file mode 100644 index 0000000..556010d --- /dev/null +++ b/src/raes_adapters/cyborg/manifest.py @@ -0,0 +1,246 @@ +"""Evidence-bounded manifest for the selected CybORG/CAGE-2 backend.""" + +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as distribution_version +from typing import Any + +from raes_backend_protocols.capabilities import ( # type: ignore[import-untyped] + BackendCapabilitySet, + BackendManifest, + ProvisionerCapabilities, +) +from raes_backend_protocols.manifest import ( # type: ignore[import-untyped] + backend_manifest_payload, +) +from raes_contracts.apparatus import ( # type: ignore[import-untyped] + ConceptBinding, + RealizationSupportDeclaration, +) +from raes_contracts.realization_envelope import ( # type: ignore[import-untyped] + BackendRealizationEnvelopeModel, + realization_envelope_digest, + realizer_configuration_digest, + validate_backend_realization_envelope, +) +from raes_contracts.vocabulary import RealizationSupportMode # type: ignore[import-untyped] + +from .scenario import CYBORG_SCENARIO_MAPPING_VERSION + +CYBORG_BACKEND_NAME = "cyborg-cage2" +CYBORG_PROFILE_ID = "cage2-cyborg-2.1-source-26ce1c1" + +_SUPPORTED_CONTRACTS = frozenset( + { + "backend-manifest-v2", + "operation-receipt-v1", + "operation-status-v1", + "provisioning-plan-v1", + "realization-envelope-v1", + "runtime-snapshot-v1", + } +) + + +def _current_backend_version() -> str: + try: + return distribution_version("raes-adapters") + except PackageNotFoundError: + return "0.0.0+unknown" + + +def create_cyborg_realization_envelope( + *, + seed: int | None = None, +) -> BackendRealizationEnvelopeModel: + """Return the validated disclosure for generated native construction.""" + + configuration: dict[str, Any] = { + "mode": "raes-scenario-projection", + "architecture": "mixed", + "image_policy": ( + f"{CYBORG_PROFILE_ID}:{CYBORG_SCENARIO_MAPPING_VERSION}:" + "linux=linux_decoy_host,windows=windows_user_host1" + ), + "network_policy": ( + "cyborg-v2.1-private-address-allocation;" + f"seed={seed if seed is not None else 'unseeded'}" + ), + "supported_node_types": ["switch", "vm"], + "supported_os_families": ["linux", "windows"], + "supported_content_types": [], + "supported_account_features": [], + "supported_domain_profiles": [], + "supports_acls": False, + "memory_mib": {"minimum": 1, "maximum": None}, + "vcpus": {"minimum": 1, "maximum": None}, + } + configuration["configuration_digest"] = realizer_configuration_digest(configuration) + unsupported: dict[str, Any] = { + "disposition": "unsupported", + "observation_strength": "none", + "mechanism": None, + "transformations": [], + } + descriptor: dict[str, Any] = { + "disposition": "descriptor-only", + "observation_strength": "driver-reported", + "mechanism": "generated-raes-scenario-v1", + "transformations": [], + } + topology: dict[str, Any] = { + "disposition": "transformed", + "observation_strength": "driver-reported", + "mechanism": "generated-raes-scenario-v1", + "transformations": ["bounded-normalization"], + } + image: dict[str, Any] = { + "disposition": "transformed", + "observation_strength": "driver-reported", + "mechanism": "selected-cyborg-image-catalog", + "transformations": ["image-substitution", "default-substitution"], + } + network: dict[str, Any] = { + "disposition": "transformed", + "observation_strength": "driver-reported", + "mechanism": "cyborg-v2.1-private-address-allocation", + "transformations": ["descriptor-substitution"], + } + payload: dict[str, Any] = { + "schema_version": "realization-envelope/v1", + "contract_id": "realization-envelope-v1", + "id": "cyborg-cage2.raes-scenario.v1", + "expression": { + "schema_version": "realization-envelope/v1", + "id": "cyborg-cage2.raes-scenario.expression.v1", + "scope": "scenario", + "domains": {}, + "bindings": [], + "closure": [], + }, + "configuration": configuration, + "concerns": [ + {"concern": "topology", **topology}, + {"concern": "architecture", **descriptor}, + {"concern": "image", **image}, + {"concern": "resource-allocation", **unsupported}, + {"concern": "network", **network}, + {"concern": "content-placement", **unsupported}, + {"concern": "account-placement", **unsupported}, + {"concern": "feature-binding", **unsupported}, + {"concern": "service", **unsupported}, + {"concern": "acl", **unsupported}, + ], + } + payload["digest"] = realization_envelope_digest(payload) + return validate_backend_realization_envelope(payload) + + +def _provisioner_capabilities( + envelope: BackendRealizationEnvelopeModel, +) -> ProvisionerCapabilities: + configuration = envelope.configuration + return ProvisionerCapabilities( + name="cyborg-cage2-provisioner", + supported_node_types=frozenset(configuration.supported_node_types), + supported_os_families=frozenset(configuration.supported_os_families), + supported_content_types=frozenset(configuration.supported_content_types), + supported_account_features=frozenset(configuration.supported_account_features), + supported_domain_profiles=frozenset(configuration.supported_domain_profiles), + supported_service_materialization_profiles=frozenset(), + max_total_nodes=None, + supports_acls=configuration.supports_acls, + supports_accounts=bool(configuration.supported_account_features), + constraints={ + "scenario_projection": ( + "The complete admitted RAES network and VM desired state is " + "deterministically projected into a generated CybORG scenario." + ) + }, + ) + + +def create_cyborg_manifest(**config: object) -> BackendManifest: + """Return the provisioning-only manifest for the selected backend.""" + + seed = config.get("seed") + if seed is not None and (type(seed) is not int or not 0 <= seed <= 0xFFFFFFFF): + raise ValueError("CybORG seed must be an unsigned 32-bit integer.") + envelope = create_cyborg_realization_envelope( + seed=seed if isinstance(seed, int) else None, + ) + manifest = BackendManifest( + name=CYBORG_BACKEND_NAME, + version=_current_backend_version(), + supported_contract_versions=_SUPPORTED_CONTRACTS, + compatible_processors=frozenset({"raes-reference-processor"}), + concept_bindings=( + ConceptBinding( + scope="capabilities.provisioner.supported_node_types", + family="assets", + ), + ConceptBinding( + scope="capabilities.provisioner.supported_os_families", + family="assets", + ), + ConceptBinding( + scope="capabilities.provisioner.supported_content_types", + family="tools-and-artifacts", + ), + ConceptBinding( + scope="capabilities.provisioner.supported_account_features", + family="identities", + ), + ConceptBinding( + scope="capabilities.provisioner.supported_domain_profiles", + family="identities", + ), + ConceptBinding( + scope="capabilities.provisioner.supported_service_materialization_profiles", + family="tools-and-artifacts", + ), + ), + realization_support=( + RealizationSupportDeclaration( + domain="runtime-realization", + support_mode=RealizationSupportMode.CONSTRAINED, + supported_constraint_kinds=frozenset({"node-type", "os-family"}), + supported_exact_requirement_kinds=frozenset({"declared-capability-match"}), + disclosure_kinds=frozenset( + { + "backend-manifest-v2", + "operation-status-v1", + "runtime-snapshot-v1", + } + ), + ), + ), + capabilities=BackendCapabilitySet( + provisioner=_provisioner_capabilities(envelope), + ), + realization_envelope=envelope, + constraints={ + "profile": CYBORG_PROFILE_ID, + "source_installation": ( + "CybORG 2.1 must be installed from the maintainer-selected CAGE-2 " + "source profile until its packaging fix is published." + ), + "equivalence": ( + "Provisioning records and constructs the admitted RAES topology; " + "CybORG substitutes selected OS images and native private addresses. " + "This provisioning-only target makes no action, observation, " + "evaluation, or outcome-equivalence claim." + ), + }, + ) + backend_manifest_payload(manifest) + return manifest + + +__all__ = [ + "CYBORG_BACKEND_NAME", + "CYBORG_PROFILE_ID", + "create_cyborg_manifest", + "create_cyborg_realization_envelope", +] diff --git a/src/raes_adapters/cyborg/mapping/README.md b/src/raes_adapters/cyborg/mapping/README.md index 8f379db..f06280d 100644 --- a/src/raes_adapters/cyborg/mapping/README.md +++ b/src/raes_adapters/cyborg/mapping/README.md @@ -2,8 +2,9 @@ This backend-local evidence bridges the source closure qualified by issue #12 to the published RAES surfaces that can carry each CAGE-2 fact. It does not -author an SDL scenario or experiment, implement a backend, change the -qualification's `not-admissible` result, or establish equivalence. +author an SDL scenario or experiment or establish equivalence. Maintainer +selection admits the backend; this ledger grades what RAES can record and what +remains lossy. Per RAES ADR-069 §2 and `docs/decisions/cage-2-replication-design.md`, the mapping from upstream CAGE-2/CybORG source facts to portable RAES artifacts is a diff --git a/src/raes_adapters/cyborg/mapping/cage2-loss-disclosures.md b/src/raes_adapters/cyborg/mapping/cage2-loss-disclosures.md index 5d00775..793defd 100644 --- a/src/raes_adapters/cyborg/mapping/cage2-loss-disclosures.md +++ b/src/raes_adapters/cyborg/mapping/cage2-loss-disclosures.md @@ -4,8 +4,7 @@ These disclosures accompany the source selection `cage2-cyborg-2.1-source-26ce1c1`. Each heading is a stable ledger reference; the machine-readable tier line binds the precise ADR-069 claim tiers weakened. A disclosure permits a bounded claim with the stated weakness. It does not -upgrade the qualification's `not-admissible` decision or establish any -equivalence tier. +prevent use of the selected backend and does not establish any equivalence tier. ## loss-scenario-user3-port-mismatch diff --git a/src/raes_adapters/cyborg/provisioner.py b/src/raes_adapters/cyborg/provisioner.py new file mode 100644 index 0000000..957ac1f --- /dev/null +++ b/src/raes_adapters/cyborg/provisioner.py @@ -0,0 +1,361 @@ +"""Aggregate RAES Provisioner for the selected CybORG/CAGE-2 backend.""" + +from __future__ import annotations + +import copy +import threading +from dataclasses import dataclass + +from raes_contracts.contracts import ( # type: ignore[import-untyped] + RealizationEnvelopeIdentityModel, +) +from raes_contracts.diagnostics import Diagnostic, Severity # type: ignore[import-untyped] +from raes_contracts.planning import ( # type: ignore[import-untyped] + ChangeAction, + ProvisioningPlan, + RuntimeDomain, +) +from raes_contracts.runtime_state import ( # type: ignore[import-untyped] + ApplyResult, + RuntimeSnapshot, + SnapshotEntry, +) + +from .driver import ( + CyborgDriver, +) +from .scenario import ( + CYBORG_SCENARIO_MAPPING_VERSION, + CyborgScenarioDescriptor, + copied_resource, + validate_scenario_resources, +) + +_DOMAIN = "runtime" +_SUPPORTED_RESOURCE_TYPES = frozenset({"network", "node"}) + + +@dataclass +class _Reconciliation: + entries: dict[str, SnapshotEntry] + changed_addresses: list[str] + + +class CyborgProvisioner: + """Construct one private CybORG scenario from an admitted RAES plan.""" + + def __init__( + self, + driver: CyborgDriver, + *, + realization_envelope: RealizationEnvelopeIdentityModel, + profile_id: str, + source_commit: str, + seed: int | None, + ) -> None: + self._driver = driver + self._realization_envelope = realization_envelope + self._profile_id = profile_id + self._source_commit = source_commit + self._seed = seed + self._active: object | None = None + self._active_available = False + self._pending_cleanup: list[object] = [] + self._lock = threading.RLock() + + def validate(self, plan: ProvisioningPlan) -> list[Diagnostic]: + """Validate backend-specific construction facts before native effects.""" + + if not isinstance(plan, ProvisioningPlan): + return [ + _diagnostic( + "cyborg-backend.invalid-plan", + "CybORG provisioner accepts only published RAES provisioning plans.", + ) + ] + diagnostics = list(plan.diagnostics) + diagnostics.extend(self._identity_diagnostics(plan, RuntimeSnapshot())) + diagnostics.extend(_resource_diagnostics(plan)) + return diagnostics + + def apply(self, plan: ProvisioningPlan, snapshot: RuntimeSnapshot) -> ApplyResult: + """Reconcile a complete desired state and atomically own one backend.""" + + with self._lock: + if not isinstance(plan, ProvisioningPlan): + return _failure( + snapshot, + _diagnostic( + "cyborg-backend.invalid-plan", + "CybORG provisioner accepts only published RAES provisioning plans.", + ), + ) + diagnostics = [ + *plan.diagnostics, + *self._identity_diagnostics(plan, snapshot), + *_resource_diagnostics(plan), + ] + if any(item.is_error for item in diagnostics): + return ApplyResult( + success=False, + snapshot=snapshot, + diagnostics=diagnostics, + ) + if not self._retry_pending_cleanup(): + return _failure(snapshot, _cleanup_failed_diagnostic()) + + reconciliation = _reconcile(plan) + if not reconciliation.changed_addresses: + if reconciliation.entries and not self._active_available: + if self._active is not None: + if not self._cleanup_handle(self._active): + return _failure(snapshot, _cleanup_failed_diagnostic()) + self._active = None + return self._replace_backend(plan, snapshot, reconciliation) + return _success(snapshot, reconciliation, self._realization_envelope) + + if not reconciliation.entries: + if not self.cleanup(): + return _failure(snapshot, _cleanup_failed_diagnostic()) + return _success(snapshot, reconciliation, self._realization_envelope) + + return self._replace_backend(plan, snapshot, reconciliation) + + def cleanup(self) -> bool: + """Release all owned and pending native state; repeated calls are safe.""" + + with self._lock: + succeeded = self._retry_pending_cleanup() + if self._active is not None: + handle = self._active + if self._cleanup_handle(handle): + self._active = None + self._active_available = False + else: + self._active_available = False + succeeded = False + return succeeded + + def _replace_backend( + self, + plan: ProvisioningPlan, + snapshot: RuntimeSnapshot, + reconciliation: _Reconciliation, + ) -> ApplyResult: + descriptor = self._descriptor(plan) + try: + candidate = self._driver.construct(descriptor, seed=self._seed) + except Exception: + return _failure( + snapshot, + _diagnostic( + "cyborg-backend.driver.construction-failed", + "The CybORG backend could not be constructed.", + ), + ) + + if self._active is not None and not self._cleanup_handle(self._active): + self._active_available = False + if not self._cleanup_handle(candidate): + self._pending_cleanup.append(candidate) + return _failure(snapshot, _cleanup_failed_diagnostic()) + + self._active = candidate + self._active_available = True + return _success(snapshot, reconciliation, self._realization_envelope) + + def _descriptor(self, plan: ProvisioningPlan) -> CyborgScenarioDescriptor: + resources = tuple( + copied_resource( + address=resource.address, + resource_type=resource.resource_type, + payload=resource.payload, + ordering_dependencies=resource.ordering_dependencies, + refresh_dependencies=resource.refresh_dependencies, + ) + for resource in sorted(plan.resources.values(), key=lambda item: item.address) + ) + return CyborgScenarioDescriptor( + profile_id=self._profile_id, + source_commit=self._source_commit, + mapping_version=CYBORG_SCENARIO_MAPPING_VERSION, + resources=resources, + ) + + def _identity_diagnostics( + self, + plan: ProvisioningPlan, + snapshot: RuntimeSnapshot, + ) -> list[Diagnostic]: + if plan.realization_envelope is None: + return [ + _diagnostic( + "cyborg-backend.realization-envelope.missing", + "Provisioning plan is missing realization envelope identity.", + ) + ] + if plan.realization_envelope != self._realization_envelope: + return [ + _diagnostic( + "cyborg-backend.realization-envelope.mismatch", + "Provisioning plan does not target the configured CybORG realization.", + ) + ] + if ( + snapshot.realization_envelope is not None + and snapshot.realization_envelope != self._realization_envelope + ) or (snapshot.entries and snapshot.realization_envelope is None): + return [ + _diagnostic( + "cyborg-backend.realization-envelope.baseline-mismatch", + "Runtime snapshot does not belong to the configured CybORG realization.", + ) + ] + return [] + + def _retry_pending_cleanup(self) -> bool: + pending: list[object] = [] + for handle in self._pending_cleanup: + if not self._cleanup_handle(handle): + pending.append(handle) + self._pending_cleanup = pending + return not pending + + def _cleanup_handle(self, handle: object) -> bool: + try: + return self._driver.cleanup(handle) is True + except Exception: + return False + + +def _resource_diagnostics( + plan: ProvisioningPlan, +) -> list[Diagnostic]: + if not _plan_matches_complete_desired_state(plan): + return [ + _diagnostic( + "cyborg-backend.plan.inconsistent-projection", + "Provisioning operations do not match the complete desired RAES resource set.", + ) + ] + if any( + operation.resource_type not in _SUPPORTED_RESOURCE_TYPES for operation in plan.operations + ): + return [ + _diagnostic( + "cyborg-backend.plan.unsupported-resource", + "The provisioning plan contains a resource CybORG cannot represent.", + ) + ] + resources = tuple( + copied_resource( + address=resource.address, + resource_type=resource.resource_type, + payload=resource.payload, + ordering_dependencies=resource.ordering_dependencies, + refresh_dependencies=resource.refresh_dependencies, + ) + for resource in sorted(plan.resources.values(), key=lambda item: item.address) + ) + if not resources: + return [] + issue = validate_scenario_resources(resources) + if issue is None: + return [] + return [_diagnostic(issue.code, issue.message)] + + +def _plan_matches_complete_desired_state(plan: ProvisioningPlan) -> bool: + active_operations = { + operation.address: operation + for operation in plan.operations + if operation.action is not ChangeAction.DELETE + } + if set(active_operations) != set(plan.resources): + return False + for address, resource in plan.resources.items(): + operation = active_operations[address] + if ( + operation.resource_type != resource.resource_type + or operation.payload != resource.payload + or operation.ordering_dependencies != resource.ordering_dependencies + or operation.refresh_dependencies != resource.refresh_dependencies + ): + return False + return all( + operation.address not in plan.resources + for operation in plan.operations + if operation.action is ChangeAction.DELETE + ) + + +def _reconcile(plan: ProvisioningPlan) -> _Reconciliation: + action_by_address = {operation.address: operation.action for operation in plan.operations} + entries = { + resource.address: SnapshotEntry( + address=resource.address, + domain=RuntimeDomain.PROVISIONING, + resource_type=resource.resource_type, + payload=copy.deepcopy(resource.payload), + ordering_dependencies=resource.ordering_dependencies, + refresh_dependencies=resource.refresh_dependencies, + status=( + "unchanged" + if action_by_address.get(resource.address) is ChangeAction.UNCHANGED + else "applied" + ), + ) + for resource in plan.resources.values() + } + changed_addresses = [ + operation.address + for operation in plan.operations + if operation.action is not ChangeAction.UNCHANGED + ] + return _Reconciliation( + entries=entries, + changed_addresses=changed_addresses, + ) + + +def _success( + snapshot: RuntimeSnapshot, + reconciliation: _Reconciliation, + identity: RealizationEnvelopeIdentityModel, +) -> ApplyResult: + return ApplyResult( + success=True, + snapshot=snapshot.with_entries( + reconciliation.entries, + realization_envelope=identity, + ), + changed_addresses=reconciliation.changed_addresses, + ) + + +def _failure(snapshot: RuntimeSnapshot, diagnostic: Diagnostic) -> ApplyResult: + return ApplyResult( + success=False, + snapshot=snapshot, + diagnostics=[diagnostic], + ) + + +def _cleanup_failed_diagnostic() -> Diagnostic: + return _diagnostic( + "cyborg-backend.driver.cleanup-failed", + "The CybORG backend could not confirm cleanup of all owned state.", + ) + + +def _diagnostic(code: str, message: str) -> Diagnostic: + return Diagnostic( + code=code, + domain=_DOMAIN, + address="runtime.cyborg.provisioning", + message=message, + severity=Severity.ERROR, + ) + + +__all__ = ["CyborgProvisioner"] diff --git a/src/raes_adapters/cyborg/qualification.json b/src/raes_adapters/cyborg/qualification.json index 78f86a7..e2f6421 100644 --- a/src/raes_adapters/cyborg/qualification.json +++ b/src/raes_adapters/cyborg/qualification.json @@ -263,6 +263,11 @@ "pythonpath": "cleared", "python_safe_path": true, "network_after_install": "not-required", + "integrity": { + "algorithm": "sha256(relative-posix-path + NUL + sha256(source-bytes))", + "python_source_count": 337, + "python_source_tree_sha256": "f74427419b587b95e2f083c69f230a28a54871977fb4432644120e7f555d2212" + }, "smoke": { "seed": 3, "scenario_exists": true, @@ -295,6 +300,14 @@ "GreenPortScan" ] } + }, + "adapter_smoke": { + "backend": "cyborg-cage2", + "constructed": true, + "cleaned": true, + "native_projection_matches": true, + "recorded_resources": 2, + "realization_recorded": true } }, "dependency_resolution": { @@ -470,14 +483,15 @@ } ], "admissibility": { - "decision": "not-admissible", - "scope": "published raes-adapters[cyborg] runtime", - "blockers": [ + "decision": "admitted", + "scope": "maintainer-selected-cyborg-backend", + "installation_limitations": [ "no-governed-public-patched-artifact", "declared-python-range-not-qualified", "declared-platform-range-not-qualified" ], "extra_disposition": "leave the cyborg extra empty rather than publish a local path, editable checkout, install-time clone, or ungoverned wheel", + "qualification_effect": "installation, replay, and equivalence claims are limited; the maintainer-selected source-installed backend remains admitted for adapter use", "requalification_trigger": "an immutable governed artifact that contains the packaging-only fix and is qualified across the declared Python and platform support boundary" } } diff --git a/src/raes_adapters/cyborg/scenario.py b/src/raes_adapters/cyborg/scenario.py new file mode 100644 index 0000000..e88d160 --- /dev/null +++ b/src/raes_adapters/cyborg/scenario.py @@ -0,0 +1,445 @@ +"""Deterministic projection of admitted RAES resources into CybORG scenario data.""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import Any + +_IMAGE_BY_OS_FAMILY = { + "linux": "linux_decoy_host", + "windows": "windows_user_host1", +} +_SUPPORTED_RESOURCE_TYPES = frozenset({"network", "node"}) +CYBORG_SCENARIO_MAPPING_VERSION = "raes-cyborg-scenario-v1" +_NETWORK_PAYLOAD_KEYS = frozenset({"name", "node_name", "spec"}) +_NODE_PAYLOAD_KEYS = frozenset( + { + "name", + "node_name", + "node_type", + "os_family", + "count", + "network_namespace_target", + "domain_topology", + "spec", + } +) +_SPEC_KEYS = frozenset({"node", "infrastructure"}) +_COMPILED_NODE_KEYS = frozenset( + { + "type", + "description", + "source", + "resources", + "os", + "os_version", + "features", + "conditions", + "injects", + "vulnerabilities", + "roles", + "services", + "asset_value", + "endpoint_persona", + "runtime", + } +) +_COMPILED_INFRASTRUCTURE_KEYS = frozenset( + { + "count", + "links", + "dependencies", + "properties", + "acls", + "description", + } +) +_NETWORK_PROPERTY_KEYS = frozenset({"cidr", "gateway", "internal"}) + + +@dataclass(frozen=True) +class CyborgScenarioResource: + """One copied admitted RAES resource supplied to native construction.""" + + address: str + resource_type: str + payload: dict[str, object] + ordering_dependencies: tuple[str, ...] + refresh_dependencies: tuple[str, ...] + + +@dataclass(frozen=True) +class CyborgScenarioDescriptor: + """Private construction input derived from a complete desired RAES state.""" + + profile_id: str + source_commit: str + mapping_version: str + resources: tuple[CyborgScenarioResource, ...] + + +@dataclass(frozen=True) +class CyborgTranslationIssue: + """One bounded reason an admitted plan cannot be represented by CybORG.""" + + code: str + message: str + + +def copied_resource( + *, + address: str, + resource_type: str, + payload: dict[str, object], + ordering_dependencies: tuple[str, ...], + refresh_dependencies: tuple[str, ...], +) -> CyborgScenarioResource: + """Copy an admitted resource so driver preparation cannot mutate the plan.""" + + return CyborgScenarioResource( + address=address, + resource_type=resource_type, + payload=copy.deepcopy(payload), + ordering_dependencies=tuple(ordering_dependencies), + refresh_dependencies=tuple(refresh_dependencies), + ) + + +def validate_scenario_resources( + resources: tuple[CyborgScenarioResource, ...], +) -> CyborgTranslationIssue | None: + """Return the first deterministic representability failure, if any.""" + + unsupported_types = sorted( + {resource.resource_type for resource in resources} - _SUPPORTED_RESOURCE_TYPES + ) + if unsupported_types: + return CyborgTranslationIssue( + "cyborg-backend.plan.unsupported-resource", + "The provisioning plan contains a resource CybORG cannot represent.", + ) + + networks = tuple(resource for resource in resources if resource.resource_type == "network") + nodes = tuple(resource for resource in resources if resource.resource_type == "node") + if not networks or not nodes: + return CyborgTranslationIssue( + "cyborg-backend.plan.incomplete-scenario", + "CybORG construction requires at least one network and one attached VM.", + ) + + network_names: set[str] = set() + for resource in networks: + issue = _validate_network(resource, network_names) + if issue is not None: + return issue + + native_host_names: set[str] = set() + for resource in nodes: + issue = _validate_node(resource, network_names, native_host_names) + if issue is not None: + return issue + return None + + +def translate_scenario( + descriptor: CyborgScenarioDescriptor, +) -> dict[str, dict[str, object]]: + """Translate a valid complete desired state into native scenario data.""" + + resources = tuple(sorted(descriptor.resources, key=lambda item: item.address)) + issue = validate_scenario_resources(resources) + if issue is not None: + raise ValueError(issue.message) + + subnets: dict[str, object] = {} + for resource in resources: + if resource.resource_type != "network": + continue + name = _required_string(resource.payload, "name") + subnets[name] = { + "Hosts": [], + "Size": 1, + "NACLs": {"all": {"in": "all", "out": "all"}}, + } + + hosts: dict[str, object] = {} + for resource in resources: + if resource.resource_type != "node": + continue + payload = resource.payload + name = _required_string(payload, "name") + count = _required_positive_int(payload, "count") + image = _IMAGE_BY_OS_FAMILY[_required_string(payload, "os_family")] + infrastructure = _required_mapping(_required_mapping(payload, "spec"), "infrastructure") + links = _required_string_list(infrastructure, "links") + for hostname in _expanded_names(name, count): + hosts[hostname] = {"image": image} + for network_name in links: + subnet = subnets[network_name] + assert isinstance(subnet, dict) + subnet_hosts = subnet["Hosts"] + assert isinstance(subnet_hosts, list) + subnet_hosts.append(hostname) + + for subnet in subnets.values(): + assert isinstance(subnet, dict) + subnet_hosts = subnet["Hosts"] + assert isinstance(subnet_hosts, list) + subnet["Size"] = max(1, len(subnet_hosts)) + + return { + "Agents": {}, + "Subnets": subnets, + "Hosts": hosts, + } + + +def _validate_network( + resource: CyborgScenarioResource, + names: set[str], +) -> CyborgTranslationIssue | None: + payload = resource.payload + try: + if set(payload) != _NETWORK_PAYLOAD_KEYS: + raise ValueError + name = _required_string(payload, "name") + if payload.get("node_name") != name: + raise ValueError + spec = _required_mapping(payload, "spec") + if set(spec) != _SPEC_KEYS: + raise ValueError + node = _required_mapping(spec, "node") + infrastructure = _required_mapping(spec, "infrastructure") + if set(node) != _COMPILED_NODE_KEYS or set(infrastructure) != _COMPILED_INFRASTRUCTURE_KEYS: + raise ValueError + count = _required_positive_int(infrastructure, "count") + links = _required_string_list(infrastructure, "links") + _required_string_list(infrastructure, "dependencies") + acls = _required_list(infrastructure, "acls") + properties = _network_properties(infrastructure) + except (KeyError, TypeError, ValueError): + return CyborgTranslationIssue( + "cyborg-backend.plan.invalid-network", + "A network resource is not a complete compiled RAES switch descriptor.", + ) + if name in names: + return CyborgTranslationIssue( + "cyborg-backend.plan.native-name-collision", + "Multiple RAES resources would produce the same native CybORG name.", + ) + names.add(name) + if node.get("type") != "switch" or count != 1: + return CyborgTranslationIssue( + "cyborg-backend.plan.unsupported-network-shape", + "CybORG represents each admitted RAES switch as exactly one subnet.", + ) + if _has_unsupported_switch_detail(node) or links or acls or properties.get("internal"): + return CyborgTranslationIssue( + "cyborg-backend.plan.unsupported-network-detail", + "Linked switches and authored network ACLs are not supported by this CybORG mapping.", + ) + return None + + +def _validate_node( + resource: CyborgScenarioResource, + network_names: set[str], + native_names: set[str], +) -> CyborgTranslationIssue | None: + payload = resource.payload + try: + if set(payload) != _NODE_PAYLOAD_KEYS: + raise ValueError + name = _required_string(payload, "name") + if payload.get("node_name") != name: + raise ValueError + count = _required_positive_int(payload, "count") + os_family = _required_string(payload, "os_family") + spec = _required_mapping(payload, "spec") + if set(spec) != _SPEC_KEYS: + raise ValueError + node = _required_mapping(spec, "node") + infrastructure = _required_mapping(spec, "infrastructure") + if set(node) != _COMPILED_NODE_KEYS or set(infrastructure) != _COMPILED_INFRASTRUCTURE_KEYS: + raise ValueError + infrastructure_count = _required_positive_int(infrastructure, "count") + links = _required_string_list(infrastructure, "links") + _required_string_list(infrastructure, "dependencies") + _required_list(infrastructure, "acls") + except (KeyError, TypeError, ValueError): + return CyborgTranslationIssue( + "cyborg-backend.plan.invalid-node", + "A node resource is not a complete compiled RAES VM descriptor.", + ) + + if payload.get("node_type") != "vm" or node.get("type") != "vm": + return CyborgTranslationIssue( + "cyborg-backend.plan.unsupported-node-type", + "The CybORG scenario mapping supports VM nodes and switch networks.", + ) + if os_family not in _IMAGE_BY_OS_FAMILY or node.get("os") != os_family: + return CyborgTranslationIssue( + "cyborg-backend.plan.unsupported-os-family", + "The CybORG scenario mapping has no selected image for the requested OS family.", + ) + if infrastructure_count != count: + return CyborgTranslationIssue( + "cyborg-backend.plan.invalid-node", + "A node resource is not a complete compiled RAES VM descriptor.", + ) + if not links: + return CyborgTranslationIssue( + "cyborg-backend.plan.unattached-node", + "Every CybORG host must be attached to at least one admitted RAES network.", + ) + if any(link not in network_names for link in links): + return CyborgTranslationIssue( + "cyborg-backend.plan.unknown-network", + "A CybORG host references a network absent from the complete desired state.", + ) + if len(links) != len(set(links)): + return CyborgTranslationIssue( + "cyborg-backend.plan.invalid-node", + "A node resource is not a complete compiled RAES VM descriptor.", + ) + if _has_unsupported_node_detail(payload, node, infrastructure): + return CyborgTranslationIssue( + "cyborg-backend.plan.unsupported-node-detail", + "The RAES node requests a fact the selected CybORG image mapping cannot preserve.", + ) + + expanded = _expanded_names(name, count) + if any(hostname in native_names for hostname in expanded): + return CyborgTranslationIssue( + "cyborg-backend.plan.native-name-collision", + "Multiple RAES resources would produce the same native CybORG name.", + ) + native_names.update(expanded) + return None + + +def _has_unsupported_node_detail( + payload: dict[str, object], + node: dict[str, object], + infrastructure: dict[str, object], +) -> bool: + unsupported_node_fields = ( + "source", + "resources", + "asset_value", + "endpoint_persona", + "runtime", + ) + unsupported_node_collections = ( + "features", + "conditions", + "injects", + "vulnerabilities", + "roles", + "services", + ) + return ( + any(node.get(field) is not None for field in unsupported_node_fields) + or any(bool(node.get(field)) for field in unsupported_node_collections) + or bool(node.get("os_version")) + or infrastructure.get("properties") is not None + or bool(infrastructure.get("acls")) + or bool(payload.get("network_namespace_target")) + or payload.get("domain_topology") is not None + ) + + +def _has_unsupported_switch_detail(node: dict[str, object]) -> bool: + unsupported_fields = ( + "source", + "resources", + "os", + "asset_value", + "endpoint_persona", + "runtime", + ) + unsupported_collections = ( + "features", + "conditions", + "injects", + "vulnerabilities", + "roles", + "services", + ) + return ( + any(node.get(field) is not None for field in unsupported_fields) + or any(bool(node.get(field)) for field in unsupported_collections) + or bool(node.get("os_version")) + ) + + +def _network_properties(infrastructure: dict[str, object]) -> dict[str, object]: + candidate = infrastructure["properties"] + if candidate is None: + return {} + if ( + not isinstance(candidate, dict) + or set(candidate) != _NETWORK_PROPERTY_KEYS + or not isinstance(candidate.get("cidr"), str) + or not candidate["cidr"] + or not isinstance(candidate.get("gateway"), str) + or not candidate["gateway"] + or type(candidate.get("internal")) is not bool + ): + raise TypeError + return candidate + + +def _expanded_names(name: str, count: int) -> tuple[str, ...]: + if count == 1: + return (name,) + return tuple(f"{name}-{index}" for index in range(count)) + + +def _required_mapping(value: dict[str, object], key: str) -> dict[str, Any]: + candidate = value[key] + if not isinstance(candidate, dict): + raise TypeError + return candidate + + +def _required_string(value: dict[str, object], key: str) -> str: + candidate = value[key] + if not isinstance(candidate, str) or not candidate: + raise TypeError + return candidate + + +def _required_positive_int(value: dict[str, object], key: str) -> int: + candidate = value[key] + if type(candidate) is not int or candidate < 1: + raise TypeError + return candidate + + +def _required_string_list(value: dict[str, object], key: str) -> tuple[str, ...]: + candidate = value[key] + if not isinstance(candidate, list) or any( + not isinstance(item, str) or not item for item in candidate + ): + raise TypeError + return tuple(candidate) + + +def _required_list(value: dict[str, object], key: str) -> list[object]: + candidate = value[key] + if not isinstance(candidate, list): + raise TypeError + return candidate + + +__all__ = [ + "CYBORG_SCENARIO_MAPPING_VERSION", + "CyborgScenarioDescriptor", + "CyborgScenarioResource", + "CyborgTranslationIssue", + "copied_resource", + "translate_scenario", + "validate_scenario_resources", +] diff --git a/src/raes_adapters/cyborg/target.py b/src/raes_adapters/cyborg/target.py new file mode 100644 index 0000000..a7d4a73 --- /dev/null +++ b/src/raes_adapters/cyborg/target.py @@ -0,0 +1,154 @@ +"""RAES target construction for the selected CybORG backend.""" + +from __future__ import annotations + +from typing import Any, cast + +from raes_backend_protocols.capabilities import ( # type: ignore[import-untyped] + BackendManifest, +) +from raes_runtime.registry import ( # type: ignore[import-untyped] + BackendRegistry, + RuntimeTarget, + RuntimeTargetComponents, +) + +from raes_adapters.base import build_runtime_target + +from . import load_qualification +from .driver import CyborgDriver, SourceInstalledCyborgDriver +from .manifest import CYBORG_BACKEND_NAME, create_cyborg_manifest +from .provisioner import CyborgProvisioner +from .source_ledger import CAGE2_SOURCE_26CE1C1 + +_CONFIG_KEYS = { + "driver", + "mapping_ledger_resource", + "qualification_profile_id", + "seed", + "simulator_version", + "source_commit", +} + + +def _normalized_config(config: dict[str, object]) -> dict[str, object]: + unknown = sorted(set(config) - _CONFIG_KEYS) + if unknown: + raise ValueError("unknown CybORG target configuration: " + ", ".join(unknown)) + qualification = load_qualification() + normalized = { + "qualification_profile_id": config.get( + "qualification_profile_id", + qualification["profile_id"], + ), + "source_commit": config.get( + "source_commit", + qualification["source"]["commit"], + ), + "simulator_version": config.get( + "simulator_version", + qualification["source"]["version"], + ), + "mapping_ledger_resource": config.get( + "mapping_ledger_resource", + CAGE2_SOURCE_26CE1C1.ledger_resource, + ), + "seed": config.get("seed"), + "driver": config.get("driver"), + } + _validate_selection(normalized, qualification) + return normalized + + +def _validate_selection( + config: dict[str, object], + qualification: dict[str, Any], +) -> None: + checks = ( + ( + "qualification_profile_id", + qualification["profile_id"], + "qualification profile", + ), + ("source_commit", qualification["source"]["commit"], "source commit"), + ( + "simulator_version", + qualification["source"]["version"], + "simulator version", + ), + ( + "mapping_ledger_resource", + CAGE2_SOURCE_26CE1C1.ledger_resource, + "mapping ledger", + ), + ) + for key, expected, label in checks: + if config[key] != expected: + raise ValueError(f"CybORG {label} does not match the selected backend.") + seed = config["seed"] + if seed is not None and (type(seed) is not int or not 0 <= seed <= 0xFFFFFFFF): + raise ValueError("CybORG seed must be an unsigned 32-bit integer.") + driver = config["driver"] + if driver is not None and ( + not callable(getattr(driver, "construct", None)) + or not callable(getattr(driver, "cleanup", None)) + ): + raise ValueError("CybORG driver does not implement construction and cleanup.") + + +def create_cyborg_components( + *, + manifest: BackendManifest, + **config: object, +) -> RuntimeTargetComponents: + """Build the Provisioner component for one selected backend.""" + + normalized = _normalized_config(config) + if manifest.has_orchestrator or manifest.has_evaluator or manifest.has_participant_runtime: + raise ValueError("Issue #15 CybORG target is provisioning-only.") + if manifest.realization_envelope is None: + raise ValueError("CybORG manifest requires a realization envelope.") + expected = create_cyborg_manifest(seed=normalized["seed"]) + if manifest.realization_envelope != expected.realization_envelope: + raise ValueError("CybORG manifest realization envelope is not selected.") + raw_driver = normalized["driver"] + driver: CyborgDriver = ( + cast(CyborgDriver, raw_driver) + if raw_driver is not None + else SourceInstalledCyborgDriver(expected_version=str(normalized["simulator_version"])) + ) + return RuntimeTargetComponents( + provisioner=CyborgProvisioner( + driver, + realization_envelope=manifest.realization_envelope.identity, + profile_id=str(normalized["qualification_profile_id"]), + source_commit=str(normalized["source_commit"]), + seed=normalized["seed"] if isinstance(normalized["seed"], int) else None, + ) + ) + + +def create_cyborg_target(**config: object) -> RuntimeTarget: + """Return the RAES target that constructs the selected CybORG backend.""" + + normalized = _normalized_config(config) + manifest = create_cyborg_manifest(**normalized) + components = create_cyborg_components(manifest=manifest, **normalized) + return build_runtime_target(CYBORG_BACKEND_NAME, manifest, components) + + +def register_cyborg_backend(registry: BackendRegistry) -> None: + """Register the selected backend through the published RAES registry.""" + + registry.register( + CYBORG_BACKEND_NAME, + create_cyborg_manifest, + create_cyborg_components, + ) + + +__all__ = [ + "create_cyborg_components", + "create_cyborg_target", + "register_cyborg_backend", +] diff --git a/tests/test_cyborg_provisioner.py b/tests/test_cyborg_provisioner.py new file mode 100644 index 0000000..73b102a --- /dev/null +++ b/tests/test_cyborg_provisioner.py @@ -0,0 +1,745 @@ +"""CybORG backend construction through published RAES provisioning contracts.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +import py_compile +import random +import sys +import textwrap +from collections import deque +from dataclasses import dataclass, field, replace +from importlib.machinery import ModuleSpec, PathFinder +from pathlib import Path +from typing import Any + +import pytest +from raes import parse_sdl +from raes_backend_protocols.manifest import backend_manifest_payload +from raes_contracts.contracts import BackendManifestV2Model +from raes_contracts.planning import ( + ChangeAction, + PlannedResource, + ProvisioningPlan, + ProvisionOp, + RuntimeDomain, +) +from raes_contracts.runtime_state import RuntimeSnapshot +from raes_runtime.manager import RuntimeManager + +from raes_adapters.cyborg import ( + CAGE2_SOURCE_26CE1C1, + CYBORG_SCENARIO_MAPPING_VERSION, + CyborgScenarioDescriptor, + SourceInstalledCyborgDriver, + create_cyborg_manifest, + create_cyborg_target, + load_qualification, + translate_scenario, +) +from raes_adapters.cyborg import driver as driver_module + +_SDL = """ +name: honest-cyborg +nodes: + user-net: {type: switch} + linux-host: {type: vm, os: linux} + windows-host: {type: vm, os: windows} +infrastructure: + user-net: + properties: {cidr: 10.20.0.0/24, gateway: 10.20.0.1} + linux-host: {count: 2, links: [user-net]} + windows-host: {count: 1, links: [user-net]} +""" + +_SECOND_SDL = """ +name: honest-cyborg +nodes: + operations: {type: switch} + linux-host: {type: vm, os: linux} +infrastructure: + operations: + properties: {cidr: 172.20.0.0/24, gateway: 172.20.0.1} + linux-host: {count: 1, links: [operations]} +""" + + +class HostileNativeHandle: + """Native handle that raises if a portable boundary tries to render it.""" + + def __str__(self) -> str: + raise AssertionError("native handle was rendered") + + def __repr__(self) -> str: + raise AssertionError("native handle was rendered") + + +@dataclass +class FakeCyborgDriver: + """Mechanical backend driver used to verify Provisioner ownership.""" + + construction_error: Exception | None = None + cleanup_outcomes: deque[bool | Exception] = field(default_factory=deque) + descriptors: list[CyborgScenarioDescriptor] = field(default_factory=list) + seeds: list[int | None] = field(default_factory=list) + handles: list[HostileNativeHandle] = field(default_factory=list) + cleanup_calls: list[object] = field(default_factory=list) + + def construct( + self, + descriptor: CyborgScenarioDescriptor, + *, + seed: int | None, + ) -> object: + self.descriptors.append(descriptor) + self.seeds.append(seed) + if self.construction_error is not None: + raise self.construction_error + handle = HostileNativeHandle() + self.handles.append(handle) + return handle + + def cleanup(self, handle: object) -> bool: + self.cleanup_calls.append(handle) + outcome = self.cleanup_outcomes.popleft() if self.cleanup_outcomes else True + if isinstance(outcome, Exception): + raise outcome + return outcome + + +def _plan(target: Any, source: str = _SDL) -> ProvisioningPlan: + execution = RuntimeManager(target).plan(parse_sdl(textwrap.dedent(source))) + assert not execution.diagnostics + return execution.provisioning + + +def _plan_with_snapshot( + target: Any, + snapshot: RuntimeSnapshot, + source: str, +) -> ProvisioningPlan: + execution = RuntimeManager(target, initial_snapshot=snapshot).plan( + parse_sdl(textwrap.dedent(source)) + ) + assert not execution.diagnostics + return execution.provisioning + + +def _delete_plan(target: Any, snapshot: RuntimeSnapshot) -> ProvisioningPlan: + return ProvisioningPlan( + resources={}, + operations=[ + ProvisionOp( + action=ChangeAction.DELETE, + address=entry.address, + resource_type=entry.resource_type, + payload={}, + ) + for entry in snapshot.entries.values() + ], + realization_envelope=target.manifest.realization_envelope.identity, + ) + + +def _replace_node_payload( + plan: ProvisioningPlan, + transform: Any, +) -> ProvisioningPlan: + node_address = next( + address for address, resource in plan.resources.items() if resource.resource_type == "node" + ) + node = plan.resources[node_address] + payload = transform(node.payload) + changed_node = replace(node, payload=payload) + resources = {**plan.resources, node_address: changed_node} + operations = [ + replace(operation, payload=payload) if operation.address == node_address else operation + for operation in plan.operations + ] + return ProvisioningPlan( + resources=resources, + operations=operations, + realization_envelope=plan.realization_envelope, + ) + + +def test_manifest_is_valid_and_discloses_the_actual_projection() -> None: + manifest = create_cyborg_manifest(seed=7) + + model = BackendManifestV2Model.model_validate(backend_manifest_payload(manifest)) + + assert model.identity.name == "cyborg-cage2" + assert model.capabilities.provisioner.name == "cyborg-cage2-provisioner" + assert manifest.realization_envelope is not None + assert manifest.has_orchestrator is False + assert manifest.has_evaluator is False + assert manifest.has_participant_runtime is False + assert manifest.has_observation is False + assert manifest.has_time is False + configuration = manifest.realization_envelope.configuration + assert configuration.mode == "raes-scenario-projection" + assert configuration.supported_node_types == ["switch", "vm"] + assert "seed=7" in configuration.network_policy + concerns = { + concern.concern.value: concern.disposition.value + for concern in manifest.realization_envelope.concerns + } + assert concerns["topology"] == "transformed" + assert concerns["image"] == "transformed" + assert concerns["network"] == "transformed" + assert concerns["resource-allocation"] == "unsupported" + assert "source_installation" in manifest.constraints + assert "equivalence" in manifest.constraints + + +def test_seed_is_bound_into_the_realization_identity() -> None: + seed_seven = create_cyborg_manifest(seed=7) + seed_eight = create_cyborg_manifest(seed=8) + unseeded = create_cyborg_manifest() + + assert seed_seven.realization_envelope is not None + assert seed_eight.realization_envelope is not None + assert unseeded.realization_envelope is not None + assert seed_seven.realization_envelope.identity != seed_eight.realization_envelope.identity + assert seed_seven.realization_envelope.identity != unseeded.realization_envelope.identity + + +def test_compiled_sdl_is_translated_to_native_topology_not_a_fixed_scenario() -> None: + first_driver = FakeCyborgDriver() + first_target = create_cyborg_target(driver=first_driver, seed=7) + first_plan = _plan(first_target) + + result = first_target.provisioner.apply(first_plan, RuntimeSnapshot()) + + assert result.success is True + assert ( + result.snapshot.realization_envelope == first_target.manifest.realization_envelope.identity + ) + assert set(result.snapshot.entries) == set(first_plan.resources) + assert first_driver.seeds == [7] + descriptor = first_driver.descriptors[0] + assert descriptor.profile_id == load_qualification()["profile_id"] + assert descriptor.source_commit == load_qualification()["source"]["commit"] + assert descriptor.mapping_version == CYBORG_SCENARIO_MAPPING_VERSION + assert ( + descriptor.resources[0].payload + is not first_plan.resources[descriptor.resources[0].address].payload + ) + + scenario = translate_scenario(descriptor) + assert scenario["Agents"] == {} + assert scenario["Subnets"] == { + "user-net": { + "Hosts": ["linux-host-0", "linux-host-1", "windows-host"], + "Size": 3, + "NACLs": {"all": {"in": "all", "out": "all"}}, + } + } + assert scenario["Hosts"] == { + "linux-host-0": {"image": "linux_decoy_host"}, + "linux-host-1": {"image": "linux_decoy_host"}, + "windows-host": {"image": "windows_user_host1"}, + } + + second_driver = FakeCyborgDriver() + second_target = create_cyborg_target(driver=second_driver, seed=7) + second_plan = _plan(second_target, _SECOND_SDL) + second = second_target.provisioner.apply(second_plan, RuntimeSnapshot()) + + assert second.success is True + second_scenario = translate_scenario(second_driver.descriptors[0]) + assert second_scenario != scenario + assert second_scenario["Subnets"] == { + "operations": { + "Hosts": ["linux-host"], + "Size": 1, + "NACLs": {"all": {"in": "all", "out": "all"}}, + } + } + + +def test_snapshot_is_the_portable_compiled_record_and_contains_no_native_handle() -> None: + driver = FakeCyborgDriver() + target = create_cyborg_target(driver=driver, seed=7) + plan = _plan(target) + authored = plan.resources["provision.node.windows-host"].payload + + result = target.provisioner.apply(plan, RuntimeSnapshot()) + + assert result.success is True + assert result.snapshot.entries["provision.node.windows-host"].payload == authored + assert result.snapshot.entries["provision.node.windows-host"].payload is not authored + assert all(handle not in result.snapshot.metadata.values() for handle in driver.handles) + assert all(handle not in result.details.values() for handle in driver.handles) + + +@pytest.mark.parametrize( + ("config", "message"), + [ + ({"qualification_profile_id": "wrong-profile"}, "qualification profile"), + ({"source_commit": "0" * 40}, "source commit"), + ({"simulator_version": "99"}, "simulator version"), + ({"mapping_ledger_resource": ("mapping", "wrong.jsonl")}, "mapping ledger"), + ({"seed": -1}, "seed"), + ({"seed": True}, "seed"), + ({"surprise": "value"}, "unknown CybORG target configuration"), + ], +) +def test_invalid_target_configuration_fails_before_backend_construction( + config: dict[str, object], + message: str, +) -> None: + driver = FakeCyborgDriver() + + with pytest.raises(ValueError, match=message): + create_cyborg_target(driver=driver, **config) + + assert driver.descriptors == [] + + +def test_plan_identity_and_unsupported_resources_fail_before_construction() -> None: + driver = FakeCyborgDriver() + target = create_cyborg_target(driver=driver) + plan = _plan(target) + mismatched = ProvisioningPlan( + resources=plan.resources, + operations=plan.operations, + realization_envelope=None, + ) + + diagnostics = target.provisioner.validate(mismatched) + assert [item.code for item in diagnostics] == ["cyborg-backend.realization-envelope.missing"] + + content = PlannedResource( + address="provision.content.payload", + domain=RuntimeDomain.PROVISIONING, + resource_type="content-placement", + payload={"content_type": "file"}, + ) + unsupported = ProvisioningPlan( + resources={content.address: content}, + operations=[ + ProvisionOp( + action=ChangeAction.CREATE, + address=content.address, + resource_type=content.resource_type, + payload=dict(content.payload), + ) + ], + realization_envelope=target.manifest.realization_envelope.identity, + ) + result = target.provisioner.apply(unsupported, RuntimeSnapshot()) + + assert result.success is False + assert result.snapshot == RuntimeSnapshot() + assert [item.code for item in result.diagnostics] == [ + "cyborg-backend.plan.unsupported-resource" + ] + assert driver.descriptors == [] + + +@pytest.mark.parametrize( + ("transform", "diagnostic_code"), + [ + ( + lambda payload: {**payload, "node_type": "container"}, + "cyborg-backend.plan.unsupported-node-type", + ), + ( + lambda payload: {**payload, "os_family": "bsd"}, + "cyborg-backend.plan.unsupported-os-family", + ), + ( + lambda payload: { + **payload, + "spec": { + **payload["spec"], + "node": {**payload["spec"]["node"], "os": "windows"}, + }, + }, + "cyborg-backend.plan.unsupported-os-family", + ), + ( + lambda payload: { + **payload, + "spec": { + **payload["spec"], + "node": { + **payload["spec"]["node"], + "source": {"name": "unmapped-image", "version": "1"}, + }, + }, + }, + "cyborg-backend.plan.unsupported-node-detail", + ), + ( + lambda payload: { + **payload, + "spec": { + **payload["spec"], + "infrastructure": { + **payload["spec"]["infrastructure"], + "links": [], + }, + }, + }, + "cyborg-backend.plan.unattached-node", + ), + ], +) +def test_lossy_or_unrepresentable_node_facts_fail_before_construction( + transform: Any, + diagnostic_code: str, +) -> None: + driver = FakeCyborgDriver() + target = create_cyborg_target(driver=driver) + unsupported = _replace_node_payload(_plan(target), transform) + + result = target.provisioner.apply(unsupported, RuntimeSnapshot()) + + assert result.success is False + assert result.snapshot == RuntimeSnapshot() + assert [item.code for item in result.diagnostics] == [diagnostic_code] + assert driver.descriptors == [] + + +def test_unknown_compiled_fields_and_inconsistent_operations_fail_closed() -> None: + driver = FakeCyborgDriver() + target = create_cyborg_target(driver=driver) + plan = _plan(target) + + unknown_field = _replace_node_payload( + plan, + lambda payload: {**payload, "unpublished_backend_hint": "ignore-me"}, + ) + unknown_result = target.provisioner.apply(unknown_field, RuntimeSnapshot()) + + inconsistent_operations = [ + ( + replace(operation, payload={**operation.payload, "unrecorded": True}) + if operation.resource_type == "node" + else operation + ) + for operation in plan.operations + ] + inconsistent = ProvisioningPlan( + resources=plan.resources, + operations=inconsistent_operations, + realization_envelope=plan.realization_envelope, + ) + inconsistent_result = target.provisioner.apply(inconsistent, RuntimeSnapshot()) + + assert [item.code for item in unknown_result.diagnostics] == [ + "cyborg-backend.plan.invalid-node" + ] + assert [item.code for item in inconsistent_result.diagnostics] == [ + "cyborg-backend.plan.inconsistent-projection" + ] + assert driver.descriptors == [] + + +def test_internal_network_request_fails_instead_of_becoming_all_allow() -> None: + driver = FakeCyborgDriver() + target = create_cyborg_target(driver=driver) + plan = _plan(target) + network_address = next( + address + for address, resource in plan.resources.items() + if resource.resource_type == "network" + ) + network = plan.resources[network_address] + payload = { + **network.payload, + "spec": { + **network.payload["spec"], + "infrastructure": { + **network.payload["spec"]["infrastructure"], + "properties": { + **network.payload["spec"]["infrastructure"]["properties"], + "internal": True, + }, + }, + }, + } + changed = replace(network, payload=payload) + resources = {**plan.resources, network_address: changed} + operations = [ + replace(operation, payload=payload) if operation.address == network_address else operation + for operation in plan.operations + ] + unsupported = ProvisioningPlan( + resources=resources, + operations=operations, + realization_envelope=plan.realization_envelope, + ) + + result = target.provisioner.apply(unsupported, RuntimeSnapshot()) + + assert [item.code for item in result.diagnostics] == [ + "cyborg-backend.plan.unsupported-network-detail" + ] + assert driver.descriptors == [] + + +def test_source_tree_is_verified_before_any_cyborg_import( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_root = tmp_path / "CybORG" + package_root.mkdir() + initializer = package_root / "__init__.py" + initializer.write_text("raise RuntimeError('must never execute')\n", encoding="utf-8") + spec = ModuleSpec("CybORG", loader=None, origin=str(initializer)) + spec.submodule_search_locations = [str(package_root)] + monkeypatch.setattr(PathFinder, "find_spec", lambda _name: spec) + monkeypatch.setattr( + driver_module, + "load_qualification", + lambda: { + "selected_files": [ + { + "path": "CybORG/CybORG/__init__.py", + "sha256": hashlib.sha256(initializer.read_bytes()).hexdigest(), + } + ], + "runtime": { + "integrity": { + "python_source_count": 1, + "python_source_tree_sha256": "0" * 64, + } + }, + }, + ) + imports: list[str] = [] + monkeypatch.setattr( + driver_module, + "import_module", + lambda name: imports.append(name), + ) + + driver = SourceInstalledCyborgDriver(expected_version="2.1") + + with pytest.raises(RuntimeError, match="does not match the selected profile"): + driver._native_binding() + + assert imports == [] + + +def test_driver_uses_generated_document_deletes_it_and_preserves_global_rng( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_driver = FakeCyborgDriver() + target = create_cyborg_target(driver=fake_driver, seed=19) + plan = _plan(target) + target.provisioner.apply(plan, RuntimeSnapshot()) + descriptor = fake_driver.descriptors[0] + observed: dict[str, Any] = {} + + class FakeNative: + def __init__(self, path: str, mode: str) -> None: + observed["path"] = path + observed["mode"] = mode + observed["scenario"] = json.loads(Path(path).read_text(encoding="utf-8")) + observed["seeded_sample"] = random.random() + + def set_seed(self, seed: int) -> None: + observed["seed"] = seed + + def reset(self) -> None: + observed["reset"] = True + + def shutdown(self) -> None: + observed["shutdown"] = True + + source_driver = SourceInstalledCyborgDriver(expected_version="2.1") + monkeypatch.setattr(source_driver, "_native_binding", lambda: FakeNative) + random.seed(1234) + before = random.getstate() + + native = source_driver.construct(descriptor, seed=19) + + assert isinstance(native, FakeNative) + assert observed["mode"] == "sim" + assert observed["scenario"] == translate_scenario(descriptor) + assert observed["seed"] == 19 + assert observed["seeded_sample"] == random.Random(19).random() + assert observed["reset"] is True + assert not Path(observed["path"]).exists() + assert random.getstate() == before + + +def test_driver_executes_a_private_source_snapshot_not_installed_bytecode( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_root = tmp_path / "CybORG" + package_root.mkdir() + initializer = package_root / "__init__.py" + initializer.write_text( + "\n".join( + ( + 'CYBORG_VERSION = "2.1"', + "class CybORG:", + ' execution_origin = "verified-source"', + ) + ) + + "\n", + encoding="utf-8", + ) + malicious_source = tmp_path / "malicious.py" + malicious_source.write_text( + "\n".join( + ( + 'CYBORG_VERSION = "2.1"', + "class CybORG:", + ' execution_origin = "unverified-bytecode"', + ) + ) + + "\n", + encoding="utf-8", + ) + cache_path = Path(importlib.util.cache_from_source(str(initializer))) + cache_path.parent.mkdir() + py_compile.compile( + str(malicious_source), + cfile=str(cache_path), + doraise=True, + invalidation_mode=py_compile.PycInvalidationMode.UNCHECKED_HASH, + ) + source_driver = SourceInstalledCyborgDriver(expected_version="2.1") + monkeypatch.setattr( + source_driver, + "_resolve_verified_package_root", + lambda: package_root, + ) + monkeypatch.setattr(source_driver, "_verify_selected_files", lambda _root: None) + monkeypatch.setattr(source_driver, "_verify_python_source_tree", lambda _root: None) + + try: + cyborg_type = source_driver._native_binding() + assert cyborg_type.execution_origin == "verified-source" + assert source_driver._runtime_workspace is not None + module_path = Path(sys.modules[cyborg_type.__module__].__file__).resolve() + assert module_path.is_relative_to(Path(source_driver._runtime_workspace.name).resolve()) + finally: + for name in tuple(sys.modules): + if name == "CybORG" or name.startswith("CybORG."): + sys.modules.pop(name, None) + if source_driver._runtime_workspace is not None: + source_driver._runtime_workspace.cleanup() + + +def test_failed_construction_returns_unchanged_snapshot_and_bounded_diagnostic() -> None: + driver = FakeCyborgDriver( + construction_error=RuntimeError( + "token=secret /native/path Traceback native-id-9981", + ) + ) + target = create_cyborg_target(driver=driver) + baseline = RuntimeSnapshot(metadata={"portable": "kept"}) + + result = target.provisioner.apply(_plan(target), baseline) + + assert result.success is False + assert result.snapshot is baseline + assert result.details == {} + assert [item.code for item in result.diagnostics] == [ + "cyborg-backend.driver.construction-failed" + ] + rendered = " ".join(item.message for item in result.diagnostics) + assert "secret" not in rendered + assert "/native/path" not in rendered + assert "Traceback" not in rendered + assert "native-id" not in rendered + + +def test_unchanged_apply_does_not_construct_a_second_backend() -> None: + driver = FakeCyborgDriver() + target = create_cyborg_target(driver=driver) + first_plan = _plan(target) + first = target.provisioner.apply(first_plan, RuntimeSnapshot()) + unchanged_plan = _plan_with_snapshot(target, first.snapshot, _SDL) + unchanged = target.provisioner.apply(unchanged_plan, first.snapshot) + + assert first.success is True + assert unchanged.success is True + assert unchanged.changed_addresses == [] + assert len(driver.descriptors) == 1 + assert driver.cleanup_calls == [] + + +def test_topology_removal_replaces_the_aggregate_with_complete_desired_state() -> None: + driver = FakeCyborgDriver() + target = create_cyborg_target(driver=driver, seed=5) + first = target.provisioner.apply(_plan(target), RuntimeSnapshot()) + replacement_plan = _plan_with_snapshot(target, first.snapshot, _SECOND_SDL) + + replacement = target.provisioner.apply(replacement_plan, first.snapshot) + + assert replacement.success is True + assert len(driver.descriptors) == 2 + assert driver.cleanup_calls == [driver.handles[0]] + assert translate_scenario(driver.descriptors[1])["Hosts"] == { + "linux-host": {"image": "linux_decoy_host"} + } + assert set(replacement.snapshot.entries) == set(replacement_plan.resources) + + +def test_failed_cleanup_retains_ownership_and_retries_until_delete_succeeds() -> None: + driver = FakeCyborgDriver(cleanup_outcomes=deque([False, True])) + target = create_cyborg_target(driver=driver) + created = target.provisioner.apply(_plan(target), RuntimeSnapshot()) + + first_delete = target.provisioner.apply( + _delete_plan(target, created.snapshot), + created.snapshot, + ) + second_delete = target.provisioner.apply( + _delete_plan(target, created.snapshot), + created.snapshot, + ) + + assert first_delete.success is False + assert first_delete.snapshot is created.snapshot + assert [item.code for item in first_delete.diagnostics] == [ + "cyborg-backend.driver.cleanup-failed" + ] + assert second_delete.success is True + assert second_delete.snapshot.entries == {} + assert driver.cleanup_calls == [driver.handles[0], driver.handles[0]] + assert target.provisioner.cleanup() is True + + +def test_failed_replacement_cleanup_compensates_candidate_without_advancing_snapshot() -> None: + driver = FakeCyborgDriver(cleanup_outcomes=deque([False, True])) + target = create_cyborg_target(driver=driver) + created = target.provisioner.apply(_plan(target), RuntimeSnapshot()) + replacement_plan = _plan_with_snapshot(target, created.snapshot, _SECOND_SDL) + + replacement = target.provisioner.apply(replacement_plan, created.snapshot) + recovery_plan = _plan_with_snapshot(target, created.snapshot, _SDL) + recovered = target.provisioner.apply(recovery_plan, created.snapshot) + + assert replacement.success is False + assert replacement.snapshot is created.snapshot + assert [item.code for item in replacement.diagnostics] == [ + "cyborg-backend.driver.cleanup-failed" + ] + assert recovered.success is True + assert len(driver.handles) == 3 + assert driver.cleanup_calls == [ + driver.handles[0], + driver.handles[1], + driver.handles[0], + ] + + +def test_selected_cyborg_source_is_admitted_and_the_installation_limit_is_disclosed() -> None: + record = load_qualification() + + assert record["admissibility"]["decision"] == "admitted" + assert record["admissibility"]["scope"] == "maintainer-selected-cyborg-backend" + assert record["packaging"]["patched_wheel"]["published"] is False + assert CAGE2_SOURCE_26CE1C1.qualification_profile_id == record["profile_id"] diff --git a/tests/test_cyborg_qualification.py b/tests/test_cyborg_qualification.py index bba9a07..3c89f4b 100644 --- a/tests/test_cyborg_qualification.py +++ b/tests/test_cyborg_qualification.py @@ -1,4 +1,4 @@ -"""Qualification evidence for the selected CybORG/CAGE-2 runtime profile.""" +"""Qualification evidence for the selected CybORG/CAGE-2 backend profile.""" from __future__ import annotations @@ -15,7 +15,14 @@ def test_resources_bind_one_immutable_cage2_source_closure() -> None: record = cyborg.load_qualification() - assert cyborg.__all__ == ["load_qualification", "read_compatibility_patch"] + assert { + "CyborgProvisioner", + "SourceInstalledCyborgDriver", + "create_cyborg_manifest", + "create_cyborg_target", + "load_qualification", + "read_compatibility_patch", + } <= set(cyborg.__all__) assert record["profile_id"] == "cage2-cyborg-2.1-source-26ce1c1" assert record["source"] == { "repository": "https://github.com/cage-challenge/cage-challenge-2", @@ -109,6 +116,13 @@ def test_clean_patched_wheel_smoke_is_bounded_and_covers_all_roles() -> None: assert runtime["pythonpath"] == "cleared" assert runtime["python_safe_path"] is True assert runtime["network_after_install"] == "not-required" + assert runtime["integrity"] == { + "algorithm": "sha256(relative-posix-path + NUL + sha256(source-bytes))", + "python_source_count": 337, + "python_source_tree_sha256": ( + "f74427419b587b95e2f083c69f230a28a54871977fb4432644120e7f555d2212" + ), + } assert smoke == { "seed": 3, "scenario_exists": True, @@ -132,6 +146,19 @@ def test_clean_patched_wheel_smoke_is_bounded_and_covers_all_roles() -> None: assert "native_state" not in serialized +def test_raes_adapter_smoke_constructs_and_cleans_the_selected_backend() -> None: + smoke = cyborg.load_qualification()["runtime"]["adapter_smoke"] + + assert smoke == { + "backend": "cyborg-cage2", + "constructed": True, + "cleaned": True, + "native_projection_matches": True, + "recorded_resources": 2, + "realization_recorded": True, + } + + def test_selection_dependencies_and_stochastic_sources_are_explicit() -> None: record = cyborg.load_qualification() selection = record["selection"] @@ -199,8 +226,9 @@ def test_legal_defect_and_fail_closed_packaging_decisions_are_explicit() -> None "scenario-user3-port-mismatch", "evaluation-seed-not-bound", } - assert record["admissibility"]["decision"] == "not-admissible" - assert set(record["admissibility"]["blockers"]) == { + assert record["admissibility"]["decision"] == "admitted" + assert record["admissibility"]["scope"] == "maintainer-selected-cyborg-backend" + assert set(record["admissibility"]["installation_limitations"]) == { "no-governed-public-patched-artifact", "declared-python-range-not-qualified", "declared-platform-range-not-qualified", diff --git a/tests/test_cyborg_qualification_driver.py b/tests/test_cyborg_qualification_driver.py index dd9862c..9020ead 100644 --- a/tests/test_cyborg_qualification_driver.py +++ b/tests/test_cyborg_qualification_driver.py @@ -8,7 +8,9 @@ from tools.verify_cyborg_qualification import ( normalized_wheel_sha256, + python_source_tree_identity, sanitized_subprocess_env, + validate_adapter_smoke, validate_reproducer_runtime, validate_smoke, ) @@ -34,6 +36,25 @@ def test_zip_timestamps_do_not_change_normalized_identity(self) -> None: ) +class PythonSourceTreeIdentityTests(unittest.TestCase): + def test_initializer_and_nested_runtime_sources_are_bound(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "__init__.py").write_text("VERSION = '2.1'\n", encoding="utf-8") + nested = root / "Agents" + nested.mkdir() + runtime = nested / "runtime.py" + runtime.write_text("ACTIVE = True\n", encoding="utf-8") + + count, original = python_source_tree_identity(root) + runtime.write_text("ACTIVE = False\n", encoding="utf-8") + changed_count, changed = python_source_tree_identity(root) + + self.assertEqual(count, 2) + self.assertEqual(changed_count, 2) + self.assertNotEqual(original, changed) + + class SanitizedEnvironmentTests(unittest.TestCase): def test_only_non_secret_process_inputs_are_forwarded(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -74,6 +95,23 @@ def test_native_state_or_shape_drift_fails(self) -> None: with self.assertRaisesRegex(RuntimeError, "smoke result"): validate_smoke({"seed": 3, "step_count": 3}, expected) + def test_adapter_smoke_accepts_only_bounded_construction_and_cleanup(self) -> None: + expected = { + "backend": "cyborg-cage2", + "constructed": True, + "cleaned": True, + "recorded_resources": 2, + "realization_recorded": True, + } + + validate_adapter_smoke(dict(expected), expected) + + with self.assertRaisesRegex(RuntimeError, "adapter smoke"): + validate_adapter_smoke( + {**expected, "native_handle": "must-not-cross"}, + expected, + ) + class RuntimeValidationTests(unittest.TestCase): evidence = { diff --git a/tests/test_cyborg_smoke.py b/tests/test_cyborg_smoke.py index eb5f687..052267d 100644 --- a/tests/test_cyborg_smoke.py +++ b/tests/test_cyborg_smoke.py @@ -8,8 +8,11 @@ def test_cyborg_module_imports_without_the_extra() -> None: - assert raes_adapters.cyborg.__all__ == [ + assert { + "CyborgProvisioner", + "create_cyborg_manifest", + "create_cyborg_target", "load_qualification", "read_compatibility_patch", - ] + } <= set(raes_adapters.cyborg.__all__) assert "CybORG" not in sys.modules diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..020cb57 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +"""Repository-local verification tools.""" diff --git a/tools/verify_cyborg_qualification.py b/tools/verify_cyborg_qualification.py index d9b6418..64842b2 100644 --- a/tools/verify_cyborg_qualification.py +++ b/tools/verify_cyborg_qualification.py @@ -102,6 +102,85 @@ ) """ +_ADAPTER_SMOKE_CODE = r""" +import json +import textwrap + +from raes import parse_sdl +from raes_adapters.cyborg import ( + CYBORG_BACKEND_NAME, + SourceInstalledCyborgDriver, + create_cyborg_target, + translate_scenario, +) +from raes_contracts.runtime_state import RuntimeSnapshot +from raes_runtime.manager import RuntimeManager + +class AuditedSourceDriver: + def __init__(self): + self.delegate = SourceInstalledCyborgDriver(expected_version="2.1") + self.native_projection_matches = False + + def construct(self, descriptor, *, seed): + expected = translate_scenario(descriptor) + native = self.delegate.construct(descriptor, seed=seed) + scenario = native.environment_controller.scenario + state = native.environment_controller.state + expected_subnets = expected["Subnets"] + memberships_match = all( + scenario.get_subnet_hosts(name) == subnet["Hosts"] + for name, subnet in expected_subnets.items() + ) + self.native_projection_matches = ( + set(scenario.hosts) == set(expected["Hosts"]) + and set(scenario.subnets) == set(expected_subnets) + and scenario.agents == [] + and memberships_match + and set(state.hosts) == set(expected["Hosts"]) + and set(state.subnet_name_to_cidr) == set(expected_subnets) + ) + return native + + def cleanup(self, handle): + return self.delegate.cleanup(handle) + +driver = AuditedSourceDriver() +target = create_cyborg_target(driver=driver, seed=3) +scenario = parse_sdl( + textwrap.dedent( + ''' + name: cyborg-adapter-smoke + nodes: + user: {type: switch} + user0: {type: vm, os: windows} + infrastructure: + user: + properties: {cidr: 10.20.0.0/24, gateway: 10.20.0.1} + user0: {count: 1, links: [user]} + ''' + ) +) +execution_plan = RuntimeManager(target).plan(scenario) +if execution_plan.diagnostics: + raise RuntimeError("CybORG adapter smoke plan was not admitted") +plan = execution_plan.provisioning +result = target.provisioner.apply(plan, RuntimeSnapshot()) +cleaned = target.provisioner.cleanup() +print( + json.dumps( + { + "backend": CYBORG_BACKEND_NAME, + "constructed": result.success, + "cleaned": cleaned, + "native_projection_matches": driver.native_projection_matches, + "recorded_resources": len(result.snapshot.entries), + "realization_recorded": result.snapshot.realization_envelope is not None, + }, + sort_keys=True, + ) +) +""" + def _sha256(path: Path) -> str: digest = hashlib.sha256() @@ -152,6 +231,20 @@ def validate_smoke(actual: dict[str, Any], expected: dict[str, Any]) -> None: raise RuntimeError("CybORG qualification smoke result mismatch") +def validate_adapter_smoke(actual: dict[str, Any], expected: dict[str, Any]) -> None: + """Require the exact bounded RAES adapter construction/cleanup result.""" + + forbidden = { + "action_id", + "native_handle", + "native_state", + "observation_values", + "reward_vector", + } + if actual != expected or forbidden & set(actual): + raise RuntimeError("CybORG qualification adapter smoke result mismatch") + + def validate_reproducer_runtime( *, actual_python: str, @@ -261,6 +354,25 @@ def _verify_source_files(source: Path, selected_files: list[dict[str, str]]) -> raise RuntimeError("CybORG qualification selected source digest mismatch") +def python_source_tree_identity(package_root: Path) -> tuple[int, str]: + """Return the canonical identity used for pre-import source verification.""" + + root = package_root.resolve() + files = sorted( + root.rglob("*.py"), + key=lambda path: path.relative_to(root).as_posix(), + ) + digest = hashlib.sha256() + for path in files: + resolved = path.resolve() + if not resolved.is_relative_to(root) or not resolved.is_file(): + raise RuntimeError("CybORG qualification Python source path is invalid") + digest.update(path.relative_to(root).as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(hashlib.sha256(resolved.read_bytes()).digest()) + return len(files), digest.hexdigest() + + def verify_qualification(repo_root: Path = REPO_ROOT) -> None: """Rebuild and execute the selected profile from public immutable inputs.""" record = cyborg.load_qualification() @@ -311,6 +423,13 @@ def verify_qualification(repo_root: Path = REPO_ROOT) -> None: if any(identities[key] != source_record[key] for key in identities): raise RuntimeError("CybORG qualification source identity mismatch") _verify_source_files(source, record["selected_files"]) + integrity = runtime_record["integrity"] + source_count, source_digest = python_source_tree_identity(source / "CybORG" / "CybORG") + if ( + source_count != integrity["python_source_count"] + or source_digest != integrity["python_source_tree_sha256"] + ): + raise RuntimeError("CybORG qualification Python source identity mismatch") source_problems = source_ledger.validate_source_checkout( source, source_ledger.load_source_ledger(), @@ -498,6 +617,49 @@ def verify_qualification(repo_root: Path = REPO_ROOT) -> None: raise RuntimeError("CybORG qualification dependency metadata mismatch") validate_smoke(probe.get("smoke", {}), runtime_record["smoke"]) + adapter_dist = root / "adapter-dist" + _run( + "adapter wheel build", + [ + "uv", + "build", + "--wheel", + "--out-dir", + str(adapter_dist), + str(repo_root), + ], + cwd=root, + env=env, + ) + adapter_wheel = _single_wheel(adapter_dist) + _run( + "adapter wheel install", + [ + "uv", + "pip", + "install", + "--python", + str(patched_python), + str(adapter_wheel), + ], + cwd=root, + env=env, + ) + adapter_result = _run( + "RAES adapter construction", + [str(patched_python), "-c", _ADAPTER_SMOKE_CODE], + cwd=runtime_dir, + env=env, + ) + try: + adapter_probe = json.loads(adapter_result.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError("CybORG qualification adapter smoke output is invalid") from exc + validate_adapter_smoke( + adapter_probe, + runtime_record["adapter_smoke"], + ) + def main(argv: list[str]) -> int: if argv: From 0b0041de5efac194e7d97757b3a7c91bc1c3b5c0 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 31 Jul 2026 07:27:18 +0200 Subject: [PATCH 2/3] refactor(cyborg): satisfy strict quality gate --- src/raes_adapters/cyborg/__init__.py | 30 +-- src/raes_adapters/cyborg/driver.py | 46 +++- src/raes_adapters/cyborg/manifest.py | 4 + src/raes_adapters/cyborg/provisioner.py | 245 +++++++++++------- src/raes_adapters/cyborg/qualification.py | 24 ++ src/raes_adapters/cyborg/scenario.py | 302 +++++++++++++--------- src/raes_adapters/cyborg/source_ledger.py | 2 +- src/raes_adapters/cyborg/target.py | 6 +- 8 files changed, 419 insertions(+), 240 deletions(-) create mode 100644 src/raes_adapters/cyborg/qualification.py diff --git a/src/raes_adapters/cyborg/__init__.py b/src/raes_adapters/cyborg/__init__.py index 67407f5..1a055c6 100644 --- a/src/raes_adapters/cyborg/__init__.py +++ b/src/raes_adapters/cyborg/__init__.py @@ -11,42 +11,26 @@ from __future__ import annotations -import json -from importlib.resources import files -from typing import Any, cast - - -def load_qualification() -> dict[str, Any]: - """Load a fresh copy of the selected CAGE-2 qualification record.""" - resource = files(__package__).joinpath("qualification.json") - return cast(dict[str, Any], json.loads(resource.read_text(encoding="utf-8"))) - - -def read_compatibility_patch() -> str: - """Read the qualification-only packaging patch verbatim.""" - resource = files(__package__).joinpath("cage2-wheel-package-data.patch") - return resource.read_text(encoding="utf-8") - - -from .driver import ( # noqa: E402 +from .driver import ( CyborgDriver, SourceInstalledCyborgDriver, ) -from .manifest import ( # noqa: E402 +from .manifest import ( CYBORG_BACKEND_NAME, CYBORG_PROFILE_ID, create_cyborg_manifest, create_cyborg_realization_envelope, ) -from .provisioner import CyborgProvisioner # noqa: E402 -from .scenario import ( # noqa: E402 +from .provisioner import CyborgProvisioner +from .qualification import load_qualification, read_compatibility_patch +from .scenario import ( CYBORG_SCENARIO_MAPPING_VERSION, CyborgScenarioDescriptor, CyborgScenarioResource, translate_scenario, ) -from .source_ledger import CAGE2_SOURCE_26CE1C1 # noqa: E402 -from .target import ( # noqa: E402 +from .source_ledger import CAGE2_SOURCE_26CE1C1 +from .target import ( create_cyborg_components, create_cyborg_target, register_cyborg_backend, diff --git a/src/raes_adapters/cyborg/driver.py b/src/raes_adapters/cyborg/driver.py index 01faef7..0e994e8 100644 --- a/src/raes_adapters/cyborg/driver.py +++ b/src/raes_adapters/cyborg/driver.py @@ -14,14 +14,42 @@ from importlib import import_module, invalidate_caches from importlib.machinery import PathFinder from pathlib import Path -from typing import Any, Protocol, cast +from typing import Protocol, cast -from . import load_qualification +from .qualification import load_qualification from .scenario import CyborgScenarioDescriptor, translate_scenario _NATIVE_RANDOM_LOCK = threading.Lock() +class _NativeCyborg(Protocol): + """Minimum native instance surface used behind the driver boundary.""" + + def set_seed(self, seed: int) -> None: + """Set the simulator seed.""" + + def reset(self) -> object: + """Reset the simulator after construction.""" + + def shutdown(self) -> None: + """Release native simulator state.""" + + +class _NativeCyborgType(Protocol): + """Callable constructor surface exposed by the selected package.""" + + def __call__(self, scenario_path: str, mode: str) -> _NativeCyborg: + """Construct a simulator from one generated scenario document.""" + + +class _NativePackage(Protocol): + """Selected package attributes inspected after verified import.""" + + CYBORG_VERSION: object + CybORG: _NativeCyborgType + __file__: str + + class CyborgDriver(Protocol): """Backend-local driver seam; native handles never cross this protocol.""" @@ -37,14 +65,14 @@ def cleanup(self, handle: object) -> bool: """Try to release one owned native handle idempotently.""" -class SourceInstalledCyborgDriver: +class SourceInstalledCyborgDriver(CyborgDriver): """Construct a generated scenario with a user-installed selected CybORG source.""" def __init__(self, *, expected_version: str) -> None: self._expected_version = expected_version self._binding_lock = threading.Lock() self._runtime_workspace: tempfile.TemporaryDirectory[str] | None = None - self._cyborg_type: Any = None + self._cyborg_type: _NativeCyborgType | None = None def construct( self, @@ -56,7 +84,7 @@ def construct( cyborg_type = self._native_binding() scenario = translate_scenario(descriptor) - native: Any = None + native: _NativeCyborg | None = None scenario_path: Path | None = None try: scenario_path = self._write_scenario(scenario) @@ -85,12 +113,12 @@ def cleanup(handle: object) -> bool: """Shut down an owned CybORG backend without inspecting native output.""" try: - cast(Any, handle).shutdown() + cast(_NativeCyborg, handle).shutdown() except Exception: return False return True - def _native_binding(self) -> Any: + def _native_binding(self) -> _NativeCyborgType: """Load the constructor from a private snapshot of verified source bytes.""" with self._binding_lock: @@ -139,7 +167,7 @@ def _native_binding(self) -> Any: return cyborg_type @staticmethod - def _import_verified_snapshot(package_root: Path) -> Any: + def _import_verified_snapshot(package_root: Path) -> _NativePackage: """Import only from the private source snapshot, never installed bytecode.""" if any(name == "CybORG" or name.startswith("CybORG.") for name in sys.modules): @@ -150,7 +178,7 @@ def _import_verified_snapshot(package_root: Path) -> Any: sys.path.insert(0, parent) invalidate_caches() try: - package = cast(Any, import_module("CybORG")) + package = cast(_NativePackage, import_module("CybORG")) finally: with suppress(ValueError): sys.path.remove(parent) diff --git a/src/raes_adapters/cyborg/manifest.py b/src/raes_adapters/cyborg/manifest.py index 556010d..8fff7b5 100644 --- a/src/raes_adapters/cyborg/manifest.py +++ b/src/raes_adapters/cyborg/manifest.py @@ -44,6 +44,8 @@ def _current_backend_version() -> str: + """Return the installed adapter version without requiring a wheel install.""" + try: return distribution_version("raes-adapters") except PackageNotFoundError: @@ -140,6 +142,8 @@ def create_cyborg_realization_envelope( def _provisioner_capabilities( envelope: BackendRealizationEnvelopeModel, ) -> ProvisionerCapabilities: + """Derive manifest capabilities from the validated realization envelope.""" + configuration = envelope.configuration return ProvisionerCapabilities( name="cyborg-cage2-provisioner", diff --git a/src/raes_adapters/cyborg/provisioner.py b/src/raes_adapters/cyborg/provisioner.py index 957ac1f..21a243c 100644 --- a/src/raes_adapters/cyborg/provisioner.py +++ b/src/raes_adapters/cyborg/provisioner.py @@ -4,8 +4,9 @@ import copy import threading -from dataclasses import dataclass +from typing import NamedTuple +from raes_backend_protocols.protocols import Provisioner # type: ignore[import-untyped] from raes_contracts.contracts import ( # type: ignore[import-untyped] RealizationEnvelopeIdentityModel, ) @@ -35,13 +36,14 @@ _SUPPORTED_RESOURCE_TYPES = frozenset({"network", "node"}) -@dataclass -class _Reconciliation: +class _Reconciliation(NamedTuple): + """Portable desired-state entries and addresses changed by one plan.""" + entries: dict[str, SnapshotEntry] changed_addresses: list[str] -class CyborgProvisioner: +class CyborgProvisioner(Provisioner): # type: ignore[misc] """Construct one private CybORG scenario from an admitted RAES plan.""" def __init__( @@ -82,44 +84,12 @@ def apply(self, plan: ProvisioningPlan, snapshot: RuntimeSnapshot) -> ApplyResul """Reconcile a complete desired state and atomically own one backend.""" with self._lock: - if not isinstance(plan, ProvisioningPlan): - return _failure( - snapshot, - _diagnostic( - "cyborg-backend.invalid-plan", - "CybORG provisioner accepts only published RAES provisioning plans.", - ), - ) - diagnostics = [ - *plan.diagnostics, - *self._identity_diagnostics(plan, snapshot), - *_resource_diagnostics(plan), - ] - if any(item.is_error for item in diagnostics): - return ApplyResult( - success=False, - snapshot=snapshot, - diagnostics=diagnostics, - ) - if not self._retry_pending_cleanup(): - return _failure(snapshot, _cleanup_failed_diagnostic()) - + failure = self._apply_precondition_failure(plan, snapshot) + if failure is not None: + return failure + assert isinstance(plan, ProvisioningPlan) reconciliation = _reconcile(plan) - if not reconciliation.changed_addresses: - if reconciliation.entries and not self._active_available: - if self._active is not None: - if not self._cleanup_handle(self._active): - return _failure(snapshot, _cleanup_failed_diagnostic()) - self._active = None - return self._replace_backend(plan, snapshot, reconciliation) - return _success(snapshot, reconciliation, self._realization_envelope) - - if not reconciliation.entries: - if not self.cleanup(): - return _failure(snapshot, _cleanup_failed_diagnostic()) - return _success(snapshot, reconciliation, self._realization_envelope) - - return self._replace_backend(plan, snapshot, reconciliation) + return self._apply_reconciliation(plan, snapshot, reconciliation) def cleanup(self) -> bool: """Release all owned and pending native state; repeated calls are safe.""" @@ -142,6 +112,8 @@ def _replace_backend( snapshot: RuntimeSnapshot, reconciliation: _Reconciliation, ) -> ApplyResult: + """Construct the desired backend before retiring the current handle.""" + descriptor = self._descriptor(plan) try: candidate = self._driver.construct(descriptor, seed=self._seed) @@ -165,6 +137,8 @@ def _replace_backend( return _success(snapshot, reconciliation, self._realization_envelope) def _descriptor(self, plan: ProvisioningPlan) -> CyborgScenarioDescriptor: + """Copy the complete desired state into a private driver descriptor.""" + resources = tuple( copied_resource( address=resource.address, @@ -187,33 +161,32 @@ def _identity_diagnostics( plan: ProvisioningPlan, snapshot: RuntimeSnapshot, ) -> list[Diagnostic]: + """Validate plan and baseline realization identities.""" + + diagnostic: Diagnostic | None = None if plan.realization_envelope is None: - return [ - _diagnostic( - "cyborg-backend.realization-envelope.missing", - "Provisioning plan is missing realization envelope identity.", - ) - ] - if plan.realization_envelope != self._realization_envelope: - return [ - _diagnostic( - "cyborg-backend.realization-envelope.mismatch", - "Provisioning plan does not target the configured CybORG realization.", - ) - ] - if ( + diagnostic = _diagnostic( + "cyborg-backend.realization-envelope.missing", + "Provisioning plan is missing realization envelope identity.", + ) + elif plan.realization_envelope != self._realization_envelope: + diagnostic = _diagnostic( + "cyborg-backend.realization-envelope.mismatch", + "Provisioning plan does not target the configured CybORG realization.", + ) + elif ( snapshot.realization_envelope is not None and snapshot.realization_envelope != self._realization_envelope ) or (snapshot.entries and snapshot.realization_envelope is None): - return [ - _diagnostic( - "cyborg-backend.realization-envelope.baseline-mismatch", - "Runtime snapshot does not belong to the configured CybORG realization.", - ) - ] - return [] + diagnostic = _diagnostic( + "cyborg-backend.realization-envelope.baseline-mismatch", + "Runtime snapshot does not belong to the configured CybORG realization.", + ) + return [] if diagnostic is None else [diagnostic] def _retry_pending_cleanup(self) -> bool: + """Retry every candidate cleanup that previously could not be confirmed.""" + pending: list[object] = [] for handle in self._pending_cleanup: if not self._cleanup_handle(handle): @@ -222,50 +195,134 @@ def _retry_pending_cleanup(self) -> bool: return not pending def _cleanup_handle(self, handle: object) -> bool: + """Convert native cleanup exceptions into a bounded failure result.""" + try: return self._driver.cleanup(handle) is True except Exception: return False + def _apply_precondition_failure( + self, + plan: object, + snapshot: RuntimeSnapshot, + ) -> ApplyResult | None: + """Return a bounded failure when apply cannot safely begin.""" + + failure: ApplyResult | None = None + if not isinstance(plan, ProvisioningPlan): + failure = _failure( + snapshot, + _diagnostic( + "cyborg-backend.invalid-plan", + "CybORG provisioner accepts only published RAES provisioning plans.", + ), + ) + else: + diagnostics = [ + *plan.diagnostics, + *self._identity_diagnostics(plan, snapshot), + *_resource_diagnostics(plan), + ] + if any(item.is_error for item in diagnostics): + failure = ApplyResult( + success=False, + snapshot=snapshot, + diagnostics=diagnostics, + ) + elif not self._retry_pending_cleanup(): + failure = _failure(snapshot, _cleanup_failed_diagnostic()) + return failure + + def _apply_reconciliation( + self, + plan: ProvisioningPlan, + snapshot: RuntimeSnapshot, + reconciliation: _Reconciliation, + ) -> ApplyResult: + """Route a validated complete-state reconciliation by lifecycle shape.""" + + if not reconciliation.changed_addresses: + return self._apply_unchanged(plan, snapshot, reconciliation) + if not reconciliation.entries: + return self._apply_empty(snapshot, reconciliation) + return self._replace_backend(plan, snapshot, reconciliation) + + def _apply_unchanged( + self, + plan: ProvisioningPlan, + snapshot: RuntimeSnapshot, + reconciliation: _Reconciliation, + ) -> ApplyResult: + """Preserve a healthy backend or reconstruct an unavailable realization.""" + + if reconciliation.entries and not self._active_available: + return self._recover_unavailable(plan, snapshot, reconciliation) + return _success(snapshot, reconciliation, self._realization_envelope) + + def _recover_unavailable( + self, + plan: ProvisioningPlan, + snapshot: RuntimeSnapshot, + reconciliation: _Reconciliation, + ) -> ApplyResult: + """Conclude cleanup of an uncertain handle before reconstructing state.""" + + if self._active is not None and not self._cleanup_handle(self._active): + return _failure(snapshot, _cleanup_failed_diagnostic()) + self._active = None + return self._replace_backend(plan, snapshot, reconciliation) + + def _apply_empty( + self, + snapshot: RuntimeSnapshot, + reconciliation: _Reconciliation, + ) -> ApplyResult: + """Clean all native state before publishing an empty desired snapshot.""" + + if not self.cleanup(): + return _failure(snapshot, _cleanup_failed_diagnostic()) + return _success(snapshot, reconciliation, self._realization_envelope) + def _resource_diagnostics( plan: ProvisioningPlan, ) -> list[Diagnostic]: + """Validate complete projection and backend representability.""" + + diagnostic: Diagnostic | None = None if not _plan_matches_complete_desired_state(plan): - return [ - _diagnostic( - "cyborg-backend.plan.inconsistent-projection", - "Provisioning operations do not match the complete desired RAES resource set.", - ) - ] - if any( + diagnostic = _diagnostic( + "cyborg-backend.plan.inconsistent-projection", + "Provisioning operations do not match the complete desired RAES resource set.", + ) + elif any( operation.resource_type not in _SUPPORTED_RESOURCE_TYPES for operation in plan.operations ): - return [ - _diagnostic( - "cyborg-backend.plan.unsupported-resource", - "The provisioning plan contains a resource CybORG cannot represent.", + diagnostic = _diagnostic( + "cyborg-backend.plan.unsupported-resource", + "The provisioning plan contains a resource CybORG cannot represent.", + ) + else: + resources = tuple( + copied_resource( + address=resource.address, + resource_type=resource.resource_type, + payload=resource.payload, + ordering_dependencies=resource.ordering_dependencies, + refresh_dependencies=resource.refresh_dependencies, ) - ] - resources = tuple( - copied_resource( - address=resource.address, - resource_type=resource.resource_type, - payload=resource.payload, - ordering_dependencies=resource.ordering_dependencies, - refresh_dependencies=resource.refresh_dependencies, + for resource in sorted(plan.resources.values(), key=lambda item: item.address) ) - for resource in sorted(plan.resources.values(), key=lambda item: item.address) - ) - if not resources: - return [] - issue = validate_scenario_resources(resources) - if issue is None: - return [] - return [_diagnostic(issue.code, issue.message)] + issue = validate_scenario_resources(resources) if resources else None + if issue is not None: + diagnostic = _diagnostic(issue.code, issue.message) + return [] if diagnostic is None else [diagnostic] def _plan_matches_complete_desired_state(plan: ProvisioningPlan) -> bool: + """Prove operations and resources describe the same complete desired state.""" + active_operations = { operation.address: operation for operation in plan.operations @@ -290,6 +347,8 @@ def _plan_matches_complete_desired_state(plan: ProvisioningPlan) -> bool: def _reconcile(plan: ProvisioningPlan) -> _Reconciliation: + """Copy a validated plan into a portable runtime snapshot projection.""" + action_by_address = {operation.address: operation.action for operation in plan.operations} entries = { resource.address: SnapshotEntry( @@ -323,6 +382,8 @@ def _success( reconciliation: _Reconciliation, identity: RealizationEnvelopeIdentityModel, ) -> ApplyResult: + """Build a successful apply result from portable reconciliation state.""" + return ApplyResult( success=True, snapshot=snapshot.with_entries( @@ -334,6 +395,8 @@ def _success( def _failure(snapshot: RuntimeSnapshot, diagnostic: Diagnostic) -> ApplyResult: + """Build a bounded failed apply result without altering the snapshot.""" + return ApplyResult( success=False, snapshot=snapshot, @@ -342,6 +405,8 @@ def _failure(snapshot: RuntimeSnapshot, diagnostic: Diagnostic) -> ApplyResult: def _cleanup_failed_diagnostic() -> Diagnostic: + """Return the stable diagnostic for inconclusive native cleanup.""" + return _diagnostic( "cyborg-backend.driver.cleanup-failed", "The CybORG backend could not confirm cleanup of all owned state.", @@ -349,6 +414,8 @@ def _cleanup_failed_diagnostic() -> Diagnostic: def _diagnostic(code: str, message: str) -> Diagnostic: + """Construct one backend-local bounded error diagnostic.""" + return Diagnostic( code=code, domain=_DOMAIN, diff --git a/src/raes_adapters/cyborg/qualification.py b/src/raes_adapters/cyborg/qualification.py new file mode 100644 index 0000000..f156f1a --- /dev/null +++ b/src/raes_adapters/cyborg/qualification.py @@ -0,0 +1,24 @@ +"""Accessors for the selected CybORG/CAGE-2 qualification evidence.""" + +from __future__ import annotations + +import json +from importlib.resources import files +from typing import Any, cast + + +def load_qualification() -> dict[str, Any]: + """Load a fresh copy of the selected CAGE-2 qualification record.""" + + resource = files(__package__).joinpath("qualification.json") + return cast(dict[str, Any], json.loads(resource.read_text(encoding="utf-8"))) + + +def read_compatibility_patch() -> str: + """Read the qualification-only packaging patch verbatim.""" + + resource = files(__package__).joinpath("cage2-wheel-package-data.patch") + return resource.read_text(encoding="utf-8") + + +__all__ = ["load_qualification", "read_compatibility_patch"] diff --git a/src/raes_adapters/cyborg/scenario.py b/src/raes_adapters/cyborg/scenario.py index e88d160..e408b96 100644 --- a/src/raes_adapters/cyborg/scenario.py +++ b/src/raes_adapters/cyborg/scenario.py @@ -3,8 +3,7 @@ from __future__ import annotations import copy -from dataclasses import dataclass -from typing import Any +from typing import NamedTuple _IMAGE_BY_OS_FAMILY = { "linux": "linux_decoy_host", @@ -56,10 +55,29 @@ } ) _NETWORK_PROPERTY_KEYS = frozenset({"cidr", "gateway", "internal"}) +_INVALID_NODE_CODE = "cyborg-backend.plan.invalid-node" +_INVALID_NODE_MESSAGE = "A node resource is not a complete compiled RAES VM descriptor." + +_NetworkFacts = tuple[ + str, + dict[str, object], + int, + tuple[str, ...], + list[object], + dict[str, object], +] +_NodeFacts = tuple[ + str, + int, + str, + dict[str, object], + dict[str, object], + int, + tuple[str, ...], +] -@dataclass(frozen=True) -class CyborgScenarioResource: +class CyborgScenarioResource(NamedTuple): """One copied admitted RAES resource supplied to native construction.""" address: str @@ -69,8 +87,7 @@ class CyborgScenarioResource: refresh_dependencies: tuple[str, ...] -@dataclass(frozen=True) -class CyborgScenarioDescriptor: +class CyborgScenarioDescriptor(NamedTuple): """Private construction input derived from a complete desired RAES state.""" profile_id: str @@ -79,8 +96,7 @@ class CyborgScenarioDescriptor: resources: tuple[CyborgScenarioResource, ...] -@dataclass(frozen=True) -class CyborgTranslationIssue: +class CyborgTranslationIssue(NamedTuple): """One bounded reason an admitted plan cannot be represented by CybORG.""" code: str @@ -111,35 +127,36 @@ def validate_scenario_resources( ) -> CyborgTranslationIssue | None: """Return the first deterministic representability failure, if any.""" + issue: CyborgTranslationIssue | None = None unsupported_types = sorted( {resource.resource_type for resource in resources} - _SUPPORTED_RESOURCE_TYPES ) if unsupported_types: - return CyborgTranslationIssue( + issue = CyborgTranslationIssue( "cyborg-backend.plan.unsupported-resource", "The provisioning plan contains a resource CybORG cannot represent.", ) - - networks = tuple(resource for resource in resources if resource.resource_type == "network") - nodes = tuple(resource for resource in resources if resource.resource_type == "node") - if not networks or not nodes: - return CyborgTranslationIssue( - "cyborg-backend.plan.incomplete-scenario", - "CybORG construction requires at least one network and one attached VM.", - ) - - network_names: set[str] = set() - for resource in networks: - issue = _validate_network(resource, network_names) - if issue is not None: - return issue - - native_host_names: set[str] = set() - for resource in nodes: - issue = _validate_node(resource, network_names, native_host_names) - if issue is not None: - return issue - return None + else: + networks = tuple(resource for resource in resources if resource.resource_type == "network") + nodes = tuple(resource for resource in resources if resource.resource_type == "node") + if not networks or not nodes: + issue = CyborgTranslationIssue( + "cyborg-backend.plan.incomplete-scenario", + "CybORG construction requires at least one network and one attached VM.", + ) + else: + network_names: set[str] = set() + for resource in networks: + issue = _validate_network(resource, network_names) + if issue is not None: + break + native_host_names: set[str] = set() + if issue is None: + for resource in nodes: + issue = _validate_node(resource, network_names, native_host_names) + if issue is not None: + break + return issue def translate_scenario( @@ -199,47 +216,62 @@ def _validate_network( resource: CyborgScenarioResource, names: set[str], ) -> CyborgTranslationIssue | None: + """Validate one compiled switch and reserve its native subnet name.""" + + facts = _parse_network(resource) + issue: CyborgTranslationIssue | None = None + if facts is None: + issue = CyborgTranslationIssue( + "cyborg-backend.plan.invalid-network", + "A network resource is not a complete compiled RAES switch descriptor.", + ) + else: + name, node, count, links, acls, properties = facts + if name in names: + issue = CyborgTranslationIssue( + "cyborg-backend.plan.native-name-collision", + "Multiple RAES resources would produce the same native CybORG name.", + ) + elif node.get("type") != "switch" or count != 1: + issue = CyborgTranslationIssue( + "cyborg-backend.plan.unsupported-network-shape", + "CybORG represents each admitted RAES switch as exactly one subnet.", + ) + elif _has_unsupported_switch_detail(node) or links or acls or properties.get("internal"): + issue = CyborgTranslationIssue( + "cyborg-backend.plan.unsupported-network-detail", + "Linked switches and authored network ACLs are not supported " + "by this CybORG mapping.", + ) + else: + names.add(name) + return issue + + +def _parse_network(resource: CyborgScenarioResource) -> _NetworkFacts | None: + """Parse the exact compiled switch payload shape without accepting extras.""" + + facts: _NetworkFacts | None = None payload = resource.payload try: - if set(payload) != _NETWORK_PAYLOAD_KEYS: - raise ValueError + _require_exact_keys(payload, _NETWORK_PAYLOAD_KEYS) name = _required_string(payload, "name") - if payload.get("node_name") != name: - raise ValueError + _require_equal(payload.get("node_name"), name) spec = _required_mapping(payload, "spec") - if set(spec) != _SPEC_KEYS: - raise ValueError + _require_exact_keys(spec, _SPEC_KEYS) node = _required_mapping(spec, "node") infrastructure = _required_mapping(spec, "infrastructure") - if set(node) != _COMPILED_NODE_KEYS or set(infrastructure) != _COMPILED_INFRASTRUCTURE_KEYS: - raise ValueError + _require_exact_keys(node, _COMPILED_NODE_KEYS) + _require_exact_keys(infrastructure, _COMPILED_INFRASTRUCTURE_KEYS) count = _required_positive_int(infrastructure, "count") links = _required_string_list(infrastructure, "links") _required_string_list(infrastructure, "dependencies") acls = _required_list(infrastructure, "acls") properties = _network_properties(infrastructure) + facts = name, node, count, links, acls, properties except (KeyError, TypeError, ValueError): - return CyborgTranslationIssue( - "cyborg-backend.plan.invalid-network", - "A network resource is not a complete compiled RAES switch descriptor.", - ) - if name in names: - return CyborgTranslationIssue( - "cyborg-backend.plan.native-name-collision", - "Multiple RAES resources would produce the same native CybORG name.", - ) - names.add(name) - if node.get("type") != "switch" or count != 1: - return CyborgTranslationIssue( - "cyborg-backend.plan.unsupported-network-shape", - "CybORG represents each admitted RAES switch as exactly one subnet.", - ) - if _has_unsupported_switch_detail(node) or links or acls or properties.get("internal"): - return CyborgTranslationIssue( - "cyborg-backend.plan.unsupported-network-detail", - "Linked switches and authored network ACLs are not supported by this CybORG mapping.", - ) - return None + facts = None + return facts def _validate_node( @@ -247,76 +279,80 @@ def _validate_node( network_names: set[str], native_names: set[str], ) -> CyborgTranslationIssue | None: + """Validate one compiled VM and reserve every expanded native host name.""" + + facts = _parse_node(resource) + issue: CyborgTranslationIssue | None = None + if facts is None: + issue = CyborgTranslationIssue(_INVALID_NODE_CODE, _INVALID_NODE_MESSAGE) + else: + name, count, os_family, node, infrastructure, infrastructure_count, links = facts + if resource.payload.get("node_type") != "vm" or node.get("type") != "vm": + issue = CyborgTranslationIssue( + "cyborg-backend.plan.unsupported-node-type", + "The CybORG scenario mapping supports VM nodes and switch networks.", + ) + elif os_family not in _IMAGE_BY_OS_FAMILY or node.get("os") != os_family: + issue = CyborgTranslationIssue( + "cyborg-backend.plan.unsupported-os-family", + "The CybORG scenario mapping has no selected image for the requested OS family.", + ) + elif infrastructure_count != count: + issue = CyborgTranslationIssue(_INVALID_NODE_CODE, _INVALID_NODE_MESSAGE) + elif not links: + issue = CyborgTranslationIssue( + "cyborg-backend.plan.unattached-node", + "Every CybORG host must be attached to at least one admitted RAES network.", + ) + elif any(link not in network_names for link in links): + issue = CyborgTranslationIssue( + "cyborg-backend.plan.unknown-network", + "A CybORG host references a network absent from the complete desired state.", + ) + elif len(links) != len(set(links)): + issue = CyborgTranslationIssue(_INVALID_NODE_CODE, _INVALID_NODE_MESSAGE) + elif _has_unsupported_node_detail(resource.payload, node, infrastructure): + issue = CyborgTranslationIssue( + "cyborg-backend.plan.unsupported-node-detail", + "The RAES node requests a fact the selected CybORG image mapping cannot preserve.", + ) + else: + expanded = _expanded_names(name, count) + if any(hostname in native_names for hostname in expanded): + issue = CyborgTranslationIssue( + "cyborg-backend.plan.native-name-collision", + "Multiple RAES resources would produce the same native CybORG name.", + ) + else: + native_names.update(expanded) + return issue + + +def _parse_node(resource: CyborgScenarioResource) -> _NodeFacts | None: + """Parse the exact compiled VM payload shape without accepting extras.""" + + facts: _NodeFacts | None = None payload = resource.payload try: - if set(payload) != _NODE_PAYLOAD_KEYS: - raise ValueError + _require_exact_keys(payload, _NODE_PAYLOAD_KEYS) name = _required_string(payload, "name") - if payload.get("node_name") != name: - raise ValueError + _require_equal(payload.get("node_name"), name) count = _required_positive_int(payload, "count") os_family = _required_string(payload, "os_family") spec = _required_mapping(payload, "spec") - if set(spec) != _SPEC_KEYS: - raise ValueError + _require_exact_keys(spec, _SPEC_KEYS) node = _required_mapping(spec, "node") infrastructure = _required_mapping(spec, "infrastructure") - if set(node) != _COMPILED_NODE_KEYS or set(infrastructure) != _COMPILED_INFRASTRUCTURE_KEYS: - raise ValueError + _require_exact_keys(node, _COMPILED_NODE_KEYS) + _require_exact_keys(infrastructure, _COMPILED_INFRASTRUCTURE_KEYS) infrastructure_count = _required_positive_int(infrastructure, "count") links = _required_string_list(infrastructure, "links") _required_string_list(infrastructure, "dependencies") _required_list(infrastructure, "acls") + facts = name, count, os_family, node, infrastructure, infrastructure_count, links except (KeyError, TypeError, ValueError): - return CyborgTranslationIssue( - "cyborg-backend.plan.invalid-node", - "A node resource is not a complete compiled RAES VM descriptor.", - ) - - if payload.get("node_type") != "vm" or node.get("type") != "vm": - return CyborgTranslationIssue( - "cyborg-backend.plan.unsupported-node-type", - "The CybORG scenario mapping supports VM nodes and switch networks.", - ) - if os_family not in _IMAGE_BY_OS_FAMILY or node.get("os") != os_family: - return CyborgTranslationIssue( - "cyborg-backend.plan.unsupported-os-family", - "The CybORG scenario mapping has no selected image for the requested OS family.", - ) - if infrastructure_count != count: - return CyborgTranslationIssue( - "cyborg-backend.plan.invalid-node", - "A node resource is not a complete compiled RAES VM descriptor.", - ) - if not links: - return CyborgTranslationIssue( - "cyborg-backend.plan.unattached-node", - "Every CybORG host must be attached to at least one admitted RAES network.", - ) - if any(link not in network_names for link in links): - return CyborgTranslationIssue( - "cyborg-backend.plan.unknown-network", - "A CybORG host references a network absent from the complete desired state.", - ) - if len(links) != len(set(links)): - return CyborgTranslationIssue( - "cyborg-backend.plan.invalid-node", - "A node resource is not a complete compiled RAES VM descriptor.", - ) - if _has_unsupported_node_detail(payload, node, infrastructure): - return CyborgTranslationIssue( - "cyborg-backend.plan.unsupported-node-detail", - "The RAES node requests a fact the selected CybORG image mapping cannot preserve.", - ) - - expanded = _expanded_names(name, count) - if any(hostname in native_names for hostname in expanded): - return CyborgTranslationIssue( - "cyborg-backend.plan.native-name-collision", - "Multiple RAES resources would produce the same native CybORG name.", - ) - native_names.update(expanded) - return None + facts = None + return facts def _has_unsupported_node_detail( @@ -324,6 +360,8 @@ def _has_unsupported_node_detail( node: dict[str, object], infrastructure: dict[str, object], ) -> bool: + """Detect compiled VM facts outside the selected image mapping.""" + unsupported_node_fields = ( "source", "resources", @@ -351,6 +389,8 @@ def _has_unsupported_node_detail( def _has_unsupported_switch_detail(node: dict[str, object]) -> bool: + """Detect compiled switch facts outside the selected subnet mapping.""" + unsupported_fields = ( "source", "resources", @@ -375,6 +415,8 @@ def _has_unsupported_switch_detail(node: dict[str, object]) -> bool: def _network_properties(infrastructure: dict[str, object]) -> dict[str, object]: + """Return a strictly shaped network-property mapping.""" + candidate = infrastructure["properties"] if candidate is None: return {} @@ -392,12 +434,16 @@ def _network_properties(infrastructure: dict[str, object]) -> dict[str, object]: def _expanded_names(name: str, count: int) -> tuple[str, ...]: + """Expand one RAES counted resource into deterministic native names.""" + if count == 1: return (name,) return tuple(f"{name}-{index}" for index in range(count)) -def _required_mapping(value: dict[str, object], key: str) -> dict[str, Any]: +def _required_mapping(value: dict[str, object], key: str) -> dict[str, object]: + """Return one required mapping value or reject the payload shape.""" + candidate = value[key] if not isinstance(candidate, dict): raise TypeError @@ -405,6 +451,8 @@ def _required_mapping(value: dict[str, object], key: str) -> dict[str, Any]: def _required_string(value: dict[str, object], key: str) -> str: + """Return one required non-empty string value.""" + candidate = value[key] if not isinstance(candidate, str) or not candidate: raise TypeError @@ -412,6 +460,8 @@ def _required_string(value: dict[str, object], key: str) -> str: def _required_positive_int(value: dict[str, object], key: str) -> int: + """Return one required positive integer value, excluding booleans.""" + candidate = value[key] if type(candidate) is not int or candidate < 1: raise TypeError @@ -419,6 +469,8 @@ def _required_positive_int(value: dict[str, object], key: str) -> int: def _required_string_list(value: dict[str, object], key: str) -> tuple[str, ...]: + """Return one required list of non-empty strings as an immutable tuple.""" + candidate = value[key] if not isinstance(candidate, list) or any( not isinstance(item, str) or not item for item in candidate @@ -428,12 +480,28 @@ def _required_string_list(value: dict[str, object], key: str) -> tuple[str, ...] def _required_list(value: dict[str, object], key: str) -> list[object]: + """Return one required list value.""" + candidate = value[key] if not isinstance(candidate, list): raise TypeError return candidate +def _require_exact_keys(value: dict[str, object], expected: frozenset[str]) -> None: + """Reject a compiled mapping that omits or adds any field.""" + + if set(value) != expected: + raise ValueError + + +def _require_equal(actual: object, expected: object) -> None: + """Reject inconsistent duplicate fields in a compiled descriptor.""" + + if actual != expected: + raise ValueError + + __all__ = [ "CYBORG_SCENARIO_MAPPING_VERSION", "CyborgScenarioDescriptor", diff --git a/src/raes_adapters/cyborg/source_ledger.py b/src/raes_adapters/cyborg/source_ledger.py index 1183bdc..5f178c9 100644 --- a/src/raes_adapters/cyborg/source_ledger.py +++ b/src/raes_adapters/cyborg/source_ledger.py @@ -16,7 +16,7 @@ from raes_contracts.contracts import schema_bundle # type: ignore[import-untyped] -from . import load_qualification +from .qualification import load_qualification type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue] type JsonObject = dict[str, JsonValue] diff --git a/src/raes_adapters/cyborg/target.py b/src/raes_adapters/cyborg/target.py index a7d4a73..83deb7c 100644 --- a/src/raes_adapters/cyborg/target.py +++ b/src/raes_adapters/cyborg/target.py @@ -15,10 +15,10 @@ from raes_adapters.base import build_runtime_target -from . import load_qualification from .driver import CyborgDriver, SourceInstalledCyborgDriver from .manifest import CYBORG_BACKEND_NAME, create_cyborg_manifest from .provisioner import CyborgProvisioner +from .qualification import load_qualification from .source_ledger import CAGE2_SOURCE_26CE1C1 _CONFIG_KEYS = { @@ -32,6 +32,8 @@ def _normalized_config(config: dict[str, object]) -> dict[str, object]: + """Apply selected defaults and reject unknown or mismatched configuration.""" + unknown = sorted(set(config) - _CONFIG_KEYS) if unknown: raise ValueError("unknown CybORG target configuration: " + ", ".join(unknown)) @@ -64,6 +66,8 @@ def _validate_selection( config: dict[str, object], qualification: dict[str, Any], ) -> None: + """Require target inputs to identify exactly the selected backend source.""" + checks = ( ( "qualification_profile_id", From 794df48efcc5eb1156b49603fb0bbec2631dd300 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 31 Jul 2026 07:34:26 +0200 Subject: [PATCH 3/3] refactor(cyborg): simplify scenario validation --- src/raes_adapters/cyborg/scenario.py | 186 ++++++++++++++++++--------- 1 file changed, 124 insertions(+), 62 deletions(-) diff --git a/src/raes_adapters/cyborg/scenario.py b/src/raes_adapters/cyborg/scenario.py index e408b96..49c7e17 100644 --- a/src/raes_adapters/cyborg/scenario.py +++ b/src/raes_adapters/cyborg/scenario.py @@ -127,35 +127,74 @@ def validate_scenario_resources( ) -> CyborgTranslationIssue | None: """Return the first deterministic representability failure, if any.""" - issue: CyborgTranslationIssue | None = None + issue = _unsupported_resource_issue(resources) + if issue is None: + networks = tuple(resource for resource in resources if resource.resource_type == "network") + nodes = tuple(resource for resource in resources if resource.resource_type == "node") + issue = _validate_complete_scenario(networks, nodes) + return issue + + +def _unsupported_resource_issue( + resources: tuple[CyborgScenarioResource, ...], +) -> CyborgTranslationIssue | None: + """Reject resource kinds outside the provisioning-only mapping.""" + unsupported_types = sorted( {resource.resource_type for resource in resources} - _SUPPORTED_RESOURCE_TYPES ) if unsupported_types: - issue = CyborgTranslationIssue( + return CyborgTranslationIssue( "cyborg-backend.plan.unsupported-resource", "The provisioning plan contains a resource CybORG cannot represent.", ) - else: - networks = tuple(resource for resource in resources if resource.resource_type == "network") - nodes = tuple(resource for resource in resources if resource.resource_type == "node") - if not networks or not nodes: - issue = CyborgTranslationIssue( - "cyborg-backend.plan.incomplete-scenario", - "CybORG construction requires at least one network and one attached VM.", - ) - else: - network_names: set[str] = set() - for resource in networks: - issue = _validate_network(resource, network_names) - if issue is not None: - break - native_host_names: set[str] = set() - if issue is None: - for resource in nodes: - issue = _validate_node(resource, network_names, native_host_names) - if issue is not None: - break + return None + + +def _validate_complete_scenario( + networks: tuple[CyborgScenarioResource, ...], + nodes: tuple[CyborgScenarioResource, ...], +) -> CyborgTranslationIssue | None: + """Validate required resource families and their cross-resource names.""" + + if not networks or not nodes: + return CyborgTranslationIssue( + "cyborg-backend.plan.incomplete-scenario", + "CybORG construction requires at least one network and one attached VM.", + ) + network_names: set[str] = set() + issue = _first_network_issue(networks, network_names) + if issue is None: + issue = _first_node_issue(nodes, network_names) + return issue + + +def _first_network_issue( + networks: tuple[CyborgScenarioResource, ...], + network_names: set[str], +) -> CyborgTranslationIssue | None: + """Return the first network failure while collecting valid subnet names.""" + + issue: CyborgTranslationIssue | None = None + for resource in networks: + issue = _validate_network(resource, network_names) + if issue is not None: + break + return issue + + +def _first_node_issue( + nodes: tuple[CyborgScenarioResource, ...], + network_names: set[str], +) -> CyborgTranslationIssue | None: + """Return the first node failure while reserving valid native host names.""" + + issue: CyborgTranslationIssue | None = None + native_host_names: set[str] = set() + for resource in nodes: + issue = _validate_node(resource, network_names, native_host_names) + if issue is not None: + break return issue @@ -282,49 +321,72 @@ def _validate_node( """Validate one compiled VM and reserve every expanded native host name.""" facts = _parse_node(resource) - issue: CyborgTranslationIssue | None = None if facts is None: + return CyborgTranslationIssue(_INVALID_NODE_CODE, _INVALID_NODE_MESSAGE) + issue = _node_semantic_issue(resource.payload, facts, network_names) + if issue is None: + name, count, *_ = facts + issue = _reserve_native_names(name, count, native_names) + return issue + + +def _node_semantic_issue( + payload: dict[str, object], + facts: _NodeFacts, + network_names: set[str], +) -> CyborgTranslationIssue | None: + """Return the first semantic loss or inconsistency in parsed VM facts.""" + + _, count, os_family, node, infrastructure, infrastructure_count, links = facts + issue: CyborgTranslationIssue | None = None + if payload.get("node_type") != "vm" or node.get("type") != "vm": + issue = CyborgTranslationIssue( + "cyborg-backend.plan.unsupported-node-type", + "The CybORG scenario mapping supports VM nodes and switch networks.", + ) + elif os_family not in _IMAGE_BY_OS_FAMILY or node.get("os") != os_family: + issue = CyborgTranslationIssue( + "cyborg-backend.plan.unsupported-os-family", + "The CybORG scenario mapping has no selected image for the requested OS family.", + ) + elif infrastructure_count != count: issue = CyborgTranslationIssue(_INVALID_NODE_CODE, _INVALID_NODE_MESSAGE) + elif not links: + issue = CyborgTranslationIssue( + "cyborg-backend.plan.unattached-node", + "Every CybORG host must be attached to at least one admitted RAES network.", + ) + elif any(link not in network_names for link in links): + issue = CyborgTranslationIssue( + "cyborg-backend.plan.unknown-network", + "A CybORG host references a network absent from the complete desired state.", + ) + elif len(links) != len(set(links)): + issue = CyborgTranslationIssue(_INVALID_NODE_CODE, _INVALID_NODE_MESSAGE) + elif _has_unsupported_node_detail(payload, node, infrastructure): + issue = CyborgTranslationIssue( + "cyborg-backend.plan.unsupported-node-detail", + "The RAES node requests a fact the selected CybORG image mapping cannot preserve.", + ) + return issue + + +def _reserve_native_names( + name: str, + count: int, + native_names: set[str], +) -> CyborgTranslationIssue | None: + """Reserve expanded host names or report a native-name collision.""" + + expanded = _expanded_names(name, count) + issue: CyborgTranslationIssue | None = None + if any(hostname in native_names for hostname in expanded): + issue = CyborgTranslationIssue( + "cyborg-backend.plan.native-name-collision", + "Multiple RAES resources would produce the same native CybORG name.", + ) else: - name, count, os_family, node, infrastructure, infrastructure_count, links = facts - if resource.payload.get("node_type") != "vm" or node.get("type") != "vm": - issue = CyborgTranslationIssue( - "cyborg-backend.plan.unsupported-node-type", - "The CybORG scenario mapping supports VM nodes and switch networks.", - ) - elif os_family not in _IMAGE_BY_OS_FAMILY or node.get("os") != os_family: - issue = CyborgTranslationIssue( - "cyborg-backend.plan.unsupported-os-family", - "The CybORG scenario mapping has no selected image for the requested OS family.", - ) - elif infrastructure_count != count: - issue = CyborgTranslationIssue(_INVALID_NODE_CODE, _INVALID_NODE_MESSAGE) - elif not links: - issue = CyborgTranslationIssue( - "cyborg-backend.plan.unattached-node", - "Every CybORG host must be attached to at least one admitted RAES network.", - ) - elif any(link not in network_names for link in links): - issue = CyborgTranslationIssue( - "cyborg-backend.plan.unknown-network", - "A CybORG host references a network absent from the complete desired state.", - ) - elif len(links) != len(set(links)): - issue = CyborgTranslationIssue(_INVALID_NODE_CODE, _INVALID_NODE_MESSAGE) - elif _has_unsupported_node_detail(resource.payload, node, infrastructure): - issue = CyborgTranslationIssue( - "cyborg-backend.plan.unsupported-node-detail", - "The RAES node requests a fact the selected CybORG image mapping cannot preserve.", - ) - else: - expanded = _expanded_names(name, count) - if any(hostname in native_names for hostname in expanded): - issue = CyborgTranslationIssue( - "cyborg-backend.plan.native-name-collision", - "Multiple RAES resources would produce the same native CybORG name.", - ) - else: - native_names.update(expanded) + native_names.update(expanded) return issue