diff --git a/README.md b/README.md index e92e86c..3885522 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Container Platform defines the contract for an **Online Shop** release composed The project does not build application code or operate a Kubernetes cluster. Its target workflow turns images published to Docker Hub into a versioned release bundle pinned by digests for review, validation and promotion to the SRE Platform. -> **Status:** Release contract v1 is defined; a digest-pinned baseline candidate and profile inventory are available for review. Offline and opt-in registry metadata checks are implemented; Pull request checks are available; required-check rules are owner-configured. Compose and SRE integrations remain planned. +> **Status:** Release contract v1 is defined; a digest-pinned baseline candidate and profile inventory are available for review. Offline and opt-in registry metadata checks, PR checks and offline Compose rendering are available; required-check rules are owner-configured. Runtime integration and SRE handoff remain planned. ## Why this platform exists @@ -39,7 +39,7 @@ The two paths into the SRE Platform have different roles: - Docker Hub is the data path: Kubernetes nodes pull the approved images from `dmitryzhuravlev/online-shop-*`. - Container Platform is the release-control path: it identifies the exact image digests that are approved together as one Online Shop release. -The diagram describes target relationships. The offline validator and opt-in Docker Hub metadata checks are implemented. GitHub Actions validation checks are available; required-check configuration remains an owner action. Compose integration and SRE handoff remain planned. +The diagram describes target relationships. Offline, opt-in Docker Hub metadata, PR checks and Compose rendering are implemented; required-check configuration remains an owner action. Runtime integration and SRE handoff remain planned. ## Responsibilities @@ -91,7 +91,7 @@ See the [roadmap](docs/roadmap.md) for the planned implementation sequence. - [Version 1 JSON Schema](schemas/release-v1.schema.json) - [Non-deployable synthetic example](examples/release-v1.example.json) -The existing Dockerfiles and build/push scripts are empty, and Compose contains local-build placeholders. Their disposition is documented in the architecture; they are not a working release pipeline. +The existing Dockerfiles and build/push scripts are empty; root Compose contains legacy local-build placeholders. Use the [generated Compose workflow](docs/compose-rendering.md) for the supported profile. The scaffold is not a working release pipeline. ## Baseline candidate @@ -116,3 +116,7 @@ Add `--registry` to inspect immutable manifest/config bytes and declared platfor ## Pull request checks [GitHub Actions validation](docs/pull-request-validation.md) runs tests/package checks on Python 3.11 and 3.13 and a stable release check. New candidates receive opt-in registry validation; merged release files are immutable. No candidate changes produces an explicit successful result. The historical legacy baseline remains a negative regression fixture. Workflow availability does not configure required merge checks or grant release approval. + +## Compose configuration + +[Offline Compose rendering](docs/compose-rendering.md) generates all 12 digest-pinned services from a candidate and the packaged AML profile. Legacy baseline rendering requires explicit `--inspection-only`; its validation remains negative. Static `docker compose config` checks do not establish runtime compatibility or release approval. diff --git a/docker-compose.yaml b/docker-compose.yaml index 82430ae..555a92e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,3 +1,5 @@ +# Legacy local-build scaffold; not the supported Online Shop Compose profile. +# Canonical workflow: generate Compose JSON as documented in docs/compose-rendering.md. version: "3.8" services: diff --git a/docs/compose-rendering.md b/docs/compose-rendering.md new file mode 100644 index 0000000..86b1fb6 --- /dev/null +++ b/docs/compose-rendering.md @@ -0,0 +1,43 @@ +# Offline Compose rendering + +The canonical Compose path is generated JSON, not the root `docker-compose.yaml` legacy scaffold. Install the wheel or use `python -m pip install .` with Python 3.11+ and local Git history containing the candidate's inventory objects. + +## Profile and inputs + +The packaged `aml-online-shop-compose-v1` profile derives topology, ports and environment settings from [canonical AML Online Shop](https://github.com/DimitryZH/application-modernization-lab/tree/9960484b5c7f1568688fa6fc8eb256976d6c18a4/workloads/online-shop), pinned at `9960484b5c7f1568688fa6fc8eb256976d6c18a4`. The [profile resource](../src/container_platform/profiles/aml-online-shop-compose-v1.json) records immutable README, Kubernetes manifest, image mapping and upstream-record references. Render performs no source fetch, registry request or Docker call. + +This Compose profile is distinct from inventory profile `sre-dev-online-shop-v1`. Inventory supplies required membership, repository allowlists and platforms; the candidate supplies every exact repository and digest. Matching images do not imply runtime configuration equivalence with SRE or Kubernetes. + +All 12 components are required, including loadgenerator (10 users, rate 1). Compose DNS uses component IDs. Container ports replace Kubernetes Service translation: frontend `80 -> 8080` becomes `frontend:8080`, and email `5000 -> 8080` becomes `emailservice:8080`. Only frontend publishes `127.0.0.1:8080:8080`. Redis `/data` is ephemeral tmpfs, with no persistence or host bind. Assistant is disabled with the AML loopback sentinel; profiler settings follow AML. No credentials, assistant/cloud services, extra images or startup helper images are added. + +`depends_on` expresses startup order only. No readiness guarantees or healthcheck tools are assumed. Loadgenerator can encounter initial connection failures and would send synthetic checkout traffic if run. Static configuration is not runtime compatibility evidence. + +## Commands and results + +```text +python -m container_platform.compose candidate.json --repository . --profile aml-online-shop-compose-v1 --output compose.generated.json +container-compose candidate.json --repository . --profile aml-online-shop-compose-v1 --output compose.generated.json +``` + +Ordinary render requires all offline validation stages, including traceability. Examples are always rejected. Unsupported profiles, incompatible membership/platforms and malformed configuration fail before output is created. Candidate fields cannot supply commands, environment, mounts or includes. + +For explicit inspection of the committed legacy baseline: + +```text +python -m container_platform.compose releases/baseline-2026-10-18.json --repository . --profile aml-online-shop-compose-v1 --output compose.generated.json --inspection-only +docker compose -f compose.generated.json config --format json +``` + +Inspection-only tolerates exclusively `candidate.traceability` failures with `legacy_ineligible`; every preceding stage must pass. Successful artifact creation returns renderer exit 0 while its separate stdout JSON report retains the complete negative validation result (baseline exit 1 and 12 legacy diagnostics). The artifact records release ID, inventory revision, profile version and inspection mode without timestamps or local paths. It grants no eligibility, exception, approval or deployment authorization. Existing validator and PR release-check exit semantics are unchanged. + +Compose bytes go only to `--output`; diagnostics/report go to stdout. Invalid input leaves output untouched. Existing output requires `--overwrite`; candidate, inventory and packaged resource paths cannot be overwritten. Complete output is published atomically. Use an existing writable output directory. No `.env`, interpolation, external includes or runtime files are required. + +## Verification and limits + +Offline tests cover deterministic bytes, exact mapping and candidate digests, service addresses, loopback exposure, safe model fields, invalid inputs, inspection restrictions and output protection. Package checks exercise both rendering interfaces and profile/schema resources from a clean installed wheel outside checkout. + +The actual baseline static Compose check result is recorded below. No image pull, container execution, build or publication is part of these checks. Registry availability remains a separate opt-in validator operation; runtime smoke tests, resource sizing, shutdown/cleanup, application compatibility and deployment authorization remain future work. + +### Static observation: 2026-10-20 + +Docker Compose `v2.40.3-desktop.1` successfully normalized the inspection-only artifact generated from `releases/baseline-2026-10-18.json` at repository revision `d3cb1e755fabfabc82718106a86582074227c35f` with profile `aml-online-shop-compose-v1`. Normalized output contained exactly 12 services and the exact 12 candidate image references, frontend-only loopback publishing, no build directives and no additional images. Rendering returned 0; the embedded offline validation remained exit 1 with 12 legacy-ineligibility diagnostics. This is a configuration check, not integration/runtime validation or baseline approval. diff --git a/docs/release-contract.md b/docs/release-contract.md index 28666f3..729eebe 100644 --- a/docs/release-contract.md +++ b/docs/release-contract.md @@ -50,7 +50,7 @@ The pull reference is `repository@digest`. Tags are not accepted selectors. For 1. **Structure:** parse strictly and validate against the pinned v1 schema. This establishes field shape only. 2. **Offline release policy and opt-in registry metadata (implemented):** resolve the pinned local inventory, validate its structure and exact membership/allowlists, identity uniqueness, declared platforms and traceability eligibility. Reject examples and unresolved references. The CLI does not establish inventory approval. With explicit registry opt-in, manifest/config availability, bytes and kind/platform metadata are checked after shared prerequisites pass, independently of traceability eligibility. Layer availability is not checked. -3. **Integration (planned):** bounded Compose checks consume the selected published images. Record evidence tied to the exact candidate revision and image set. Structure and registry success do not establish service compatibility. +3. **Configuration and integration:** [offline Compose rendering](compose-rendering.md) generates digest-pinned configuration and supports static Compose checks. Explicit legacy inspection preserves negative release validation. Runtime integration remains planned; it must record evidence tied to the exact candidate revision and image set. Structure, static configuration and registry success do not establish service compatibility. 4. **Container-release review:** maintainers review composition, policy results and required integration evidence. Approval is external review evidence tied to the exact manifest/revision, not a self-asserted manifest flag. Only a release PR reviewed by authorized maintainers with all documented mandatory checks passing may use merge as the approval event. Git merge itself performs no validation. Until those checks and review gates are implemented, merging this contract or an example approves no deployable release. 5. **SRE promotion (planned):** SRE separately authorizes suitability for a specific environment through a reviewed GitOps change. It must record the full immutable Container Platform commit SHA and manifest path, and consume exactly that image set. A moving branch/tag is insufficient. Checking rendered SRE values against the manifest, making that check mandatory and protecting merges remain future implementation; bypasses are not blocked today. 6. **Deployment and runtime:** Argo CD applies environment configuration; Kubernetes pulls from Docker Hub. SRE evaluates rollout health, observability, data/state constraints and operational acceptance. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6722f5e..9108b04 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -87,7 +87,7 @@ Invalid, incomplete, mutable, or unavailable releases cannot be presented as val **Goal:** Make release validation repeatable and prove that the published images compose into an application. -[Pull request checks](pull-request-validation.md) provide tests/package and new-candidate validation. Required-check settings are owner-configured; Compose integration remains unimplemented, so this milestone is not complete. +[Pull request checks](pull-request-validation.md) provide tests/package and new-candidate validation. [Offline Compose rendering](compose-rendering.md) provides deterministic configuration from digest-pinned candidates. Required-check settings are owner-configured; runtime integration remains unimplemented, so this milestone is not complete. ### Scope diff --git a/pyproject.toml b/pyproject.toml index 1b224af..33ea7fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,9 +11,10 @@ dependencies = ["jsonschema>=4.23,<5", "referencing>=0.35,<1"] [project.scripts] container-release = "container_platform.cli:main" +container-compose = "container_platform.compose:main" [tool.setuptools] -packages = ["container_platform", "container_platform.schemas"] +packages = ["container_platform", "container_platform.schemas", "container_platform.profiles"] [tool.setuptools.package-dir] container_platform = "src/container_platform" @@ -21,3 +22,4 @@ container_platform = "src/container_platform" [tool.setuptools.package-data] "container_platform.schemas" = ["release-v1.schema.json"] +"container_platform.profiles" = ["*.json"] diff --git a/scripts/check_package.py b/scripts/check_package.py index 61881cb..84cf457 100644 --- a/scripts/check_package.py +++ b/scripts/check_package.py @@ -49,6 +49,22 @@ def check(repository, wheel_directory): reports.append(report) if reports[0] != reports[1]: raise RuntimeError("Installed module and console results differ.") + profile = "aml-online-shop-compose-v1" + resource = run([python, "-c", "from importlib.resources import files; print(files('container_platform.profiles').joinpath('aml-online-shop-compose-v1.json').read_text(encoding='utf-8'), end='')"], outside, env) + if resource != (repo / "src/container_platform/profiles" / (profile + ".json")).read_text(encoding="utf-8"): + raise RuntimeError("Packaged Compose profile differs from its source.") + compose_console = str(binaries / ("container-compose.exe" if os.name == "nt" else "container-compose")) + artifacts = [] + for index, command in enumerate(([python, "-m", "container_platform.compose"], [compose_console])): + destination = outside / f"compose-{index}.json" + report = json.loads(run([*command, str(repo / "releases/baseline-2026-10-18.json"), + "--repository", str(repo), "--profile", profile, + "--output", str(destination), "--inspection-only"], outside, env)) + if report["exitCode"] != 0 or report["validation"]["exitCode"] != 1 or not report["inspectionOnly"]: + raise RuntimeError("Installed Compose inspection semantics changed.") + artifacts.append(destination.read_bytes()) + if artifacts[0] != artifacts[1]: + raise RuntimeError("Installed rendering interfaces differ.") def main(): @@ -67,6 +83,7 @@ def main(): "### Tests and package\n\n" + ("Pass" if passed else "Fail") + ": automated offline tests; clean installed wheel; module/console and schema outside checkout.\n" + "Compose: installed offline renderer/profile; baseline inspection preserves negative validation.\n" "Baseline: expected negative regression, not release eligibility.\n", encoding="utf-8", ) diff --git a/src/container_platform/compose.py b/src/container_platform/compose.py new file mode 100644 index 0000000..f43f974 --- /dev/null +++ b/src/container_platform/compose.py @@ -0,0 +1,154 @@ +"""Offline, digest-pinned Compose rendering; never executes Docker.""" + +import json +import os +from importlib.resources import files +from pathlib import Path +import tempfile + +from .cli import Parser +from .validation import Failure, resolve_inventory, strict_json, validate + + +PROFILE = "aml-online-shop-compose-v1" +IDS = frozenset("frontend cartservice checkoutservice paymentservice adservice currencyservice emailservice productcatalogservice recommendationservice shippingservice redis-cart loadgenerator".split()) + + +def load_profile(name): + if name != PROFILE: + raise Failure("profile_unsupported", "Select the supported explicit Compose profile.") + profile = strict_json(files("container_platform.profiles").joinpath(PROFILE + ".json").read_bytes()) + services = profile.get("services", {}) + if (profile.get("profile") != PROFILE or profile.get("platform") != "linux/amd64" + or profile.get("inventoryProfile") != "sre-dev-online-shop-v1" or set(services) != IDS): + raise Failure("profile_mapping_invalid", "Packaged profile mapping is invalid.") + for service in services.values(): + if not isinstance(service, dict) or set(service) - {"environment", "expose"}: + raise Failure("profile_mapping_invalid", "Unsupported profile configuration.") + env = service.get("environment", {}) + ports = service.get("expose", []) + if (not isinstance(env, dict) or any(not isinstance(k, str) or not isinstance(v, str) or "$" in k + v for k, v in env.items()) + or not isinstance(ports, list) or any(not isinstance(p, str) or not p.isascii() or not p.isdigit() or not 1 <= int(p) <= 65535 for p in ports)): + raise Failure("profile_mapping_invalid", "Profile values must be literal environment strings and ports.") + for service in services.values(): + for key, value in service["environment"].items(): + if key.endswith("_ADDR") and value != "127.0.0.1:1": + host, separator, port = value.partition(":") + if not separator or host not in services or port not in services[host].get("expose", []): + raise Failure("profile_mapping_invalid", "Service addresses must resolve to profile container ports.") + if (services["frontend"].get("expose") != ["8080"] + or services["frontend"]["environment"].get("ENABLE_ASSISTANT") != "false" + or services["frontend"]["environment"].get("SHOPPING_ASSISTANT_SERVICE_ADDR") != "127.0.0.1:1" + or services["loadgenerator"]["environment"].get("FRONTEND_ADDR") != "frontend:8080" + or services["redis-cart"].get("expose") != ["6379"]): + raise Failure("profile_mapping_invalid", "Required profile runtime settings are inconsistent.") + return profile + + +def model(candidate, inventory, profile, inspection): + components = {c["id"]: c for c in candidate["components"]} + if (set(components) != IDS or {c["id"] for c in inventory["components"]} != IDS + or inventory["profile"] != profile["inventoryProfile"] + or any(c["image"]["platforms"] != [profile["platform"]] for c in components.values())): + raise Failure("profile_membership_mismatch", "Compose profile requires its exact inventory membership and linux/amd64 platform.") + services = {} + for identity in sorted(IDS): + image = components[identity]["image"] + config = profile["services"][identity] + service = {"image": image["repository"] + "@" + image["digest"], "platform": profile["platform"], **config} + # Container ports replace Kubernetes Service port translation (email 5000 -> 8080, + # frontend 80 -> 8080). No auxiliary proxy image is introduced. + dependencies = sorted({v.split(":")[0] for v in config["environment"].values() if ":" in v and v.split(":")[0] in IDS}) + if dependencies: + service["depends_on"] = dependencies + if identity == "frontend": + service["ports"] = [{"target": 8080, "published": "8080", "host_ip": "127.0.0.1", "protocol": "tcp"}] + if identity == "redis-cart": + service["tmpfs"] = ["/data"] + services[identity] = service + return {"services": services, "x-container-platform": { + "releaseId": candidate["releaseId"], "profile": profile["profile"], + "inventoryRevision": candidate["inventory"]["revision"], "inspectionOnly": inspection, + }} + + +def write_output(destination, document, overwrite): + """Publish complete bytes atomically; exclusive creation by default.""" + temporary = None + try: + with tempfile.NamedTemporaryFile(dir=destination.parent, prefix=".compose-", delete=False) as stream: + temporary = Path(stream.name) + stream.write(document) + if overwrite: + os.replace(temporary, destination) + else: + os.link(temporary, destination) + except FileExistsError: + raise Failure("output_exists", "Output exists; explicit --overwrite is required.") from None + except OSError: + raise Failure("output_write_failed", "Output could not be written atomically.", execution=True) from None + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def render(candidate_path, repository, profile_name, output, *, inspection_only=False, overwrite=False): + report = {"renderOutputVersion": 1, "result": "render_failed", "exitCode": 1, + "inspectionOnly": inspection_only, "validation": None, "diagnostics": [], + "scope": "Offline configuration artifact only; no registry/runtime verification, approval or deployment authorization."} + try: + profile = load_profile(profile_name) + # Validate and render the same snapshot even if the caller edits the original file. + data = Path(candidate_path).read_bytes() + with tempfile.TemporaryDirectory(prefix="compose-input-") as temporary: + snapshot = Path(temporary) / "candidate.json" + snapshot.write_bytes(data) + validation = validate(snapshot, repository) + report["validation"] = validation + checks = validation["checks"] + legacy_only = (all(c["status"] == "pass" for c in checks[:-1]) + and checks[-1]["id"] == "candidate.traceability" + and checks[-1]["status"] == "fail" and bool(checks[-1]["diagnostics"]) + and all(d["code"] == "legacy_ineligible" for d in checks[-1]["diagnostics"])) + if validation["exitCode"] != 0 and not (inspection_only and legacy_only): + report["exitCode"] = validation["exitCode"] + report["diagnostics"].append("release_validation_failed") + return report + candidate = strict_json(data) + inventory = strict_json(resolve_inventory(repository, candidate["inventory"])) + document = model(candidate, inventory, profile, inspection_only) + destination = Path(output).absolute() + protected = [Path(candidate_path), Path(repository) / candidate["inventory"]["path"], + Path(str(files("container_platform.schemas").joinpath("release-v1.schema.json"))), + Path(str(files("container_platform.profiles").joinpath(PROFILE + ".json")))] + if destination.is_symlink() or any(destination.resolve() == p.resolve() or (destination.exists() and p.exists() and destination.samefile(p)) for p in protected): + raise Failure("output_is_input", "Output must not replace an input or packaged resource.") + write_output(destination, (json.dumps(document, indent=2, sort_keys=True, ensure_ascii=True) + "\n").encode(), overwrite) + report.update(result="render_succeeded", exitCode=0) + except Failure as failure: + report.update(exitCode=2 if failure.execution else 1, diagnostics=[failure.code]) + except OSError: + report.update(exitCode=2, diagnostics=["render_io_failed"]) + return report + + +def main(argv=None): + parser = Parser(description=__doc__, allow_abbrev=False) + parser.add_argument("candidate") + parser.add_argument("--repository", required=True) + parser.add_argument("--profile", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--inspection-only", action="store_true") + parser.add_argument("--overwrite", action="store_true") + try: + args = parser.parse_args(argv) + report = render(args.candidate, args.repository, args.profile, args.output, + inspection_only=args.inspection_only, overwrite=args.overwrite) + except Failure as failure: + report = {"result": "render_failed", "exitCode": 2, "diagnostics": [failure.code]} + print(json.dumps(report, indent=2)) + return report["exitCode"] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/container_platform/profiles/__init__.py b/src/container_platform/profiles/__init__.py new file mode 100644 index 0000000..79bfb7c --- /dev/null +++ b/src/container_platform/profiles/__init__.py @@ -0,0 +1 @@ +"""Versioned offline Compose profiles.""" diff --git a/src/container_platform/profiles/aml-online-shop-compose-v1.json b/src/container_platform/profiles/aml-online-shop-compose-v1.json new file mode 100644 index 0000000..9799579 --- /dev/null +++ b/src/container_platform/profiles/aml-online-shop-compose-v1.json @@ -0,0 +1,130 @@ +{ + "inventoryProfile": "sre-dev-online-shop-v1", + "platform": "linux/amd64", + "profile": "aml-online-shop-compose-v1", + "services": { + "adservice": { + "environment": { + "PORT": "9555" + }, + "expose": [ + "9555" + ] + }, + "cartservice": { + "environment": { + "REDIS_ADDR": "redis-cart:6379" + }, + "expose": [ + "7070" + ] + }, + "checkoutservice": { + "environment": { + "CART_SERVICE_ADDR": "cartservice:7070", + "CURRENCY_SERVICE_ADDR": "currencyservice:7000", + "EMAIL_SERVICE_ADDR": "emailservice:8080", + "PAYMENT_SERVICE_ADDR": "paymentservice:50051", + "PORT": "5050", + "PRODUCT_CATALOG_SERVICE_ADDR": "productcatalogservice:3550", + "SHIPPING_SERVICE_ADDR": "shippingservice:50051" + }, + "expose": [ + "5050" + ] + }, + "currencyservice": { + "environment": { + "DISABLE_PROFILER": "1", + "PORT": "7000" + }, + "expose": [ + "7000" + ] + }, + "emailservice": { + "environment": { + "DISABLE_PROFILER": "1", + "PORT": "8080" + }, + "expose": [ + "8080" + ] + }, + "frontend": { + "environment": { + "AD_SERVICE_ADDR": "adservice:9555", + "CART_SERVICE_ADDR": "cartservice:7070", + "CHECKOUT_SERVICE_ADDR": "checkoutservice:5050", + "CURRENCY_SERVICE_ADDR": "currencyservice:7000", + "ENABLE_ASSISTANT": "false", + "ENABLE_PROFILER": "0", + "ENV_PLATFORM": "local", + "PORT": "8080", + "PRODUCT_CATALOG_SERVICE_ADDR": "productcatalogservice:3550", + "RECOMMENDATION_SERVICE_ADDR": "recommendationservice:8080", + "SHIPPING_SERVICE_ADDR": "shippingservice:50051", + "SHOPPING_ASSISTANT_SERVICE_ADDR": "127.0.0.1:1" + }, + "expose": [ + "8080" + ] + }, + "loadgenerator": { + "environment": { + "FRONTEND_ADDR": "frontend:8080", + "RATE": "1", + "USERS": "10" + } + }, + "paymentservice": { + "environment": { + "DISABLE_PROFILER": "1", + "PORT": "50051" + }, + "expose": [ + "50051" + ] + }, + "productcatalogservice": { + "environment": { + "DISABLE_PROFILER": "1", + "PORT": "3550" + }, + "expose": [ + "3550" + ] + }, + "recommendationservice": { + "environment": { + "DISABLE_PROFILER": "1", + "PORT": "8080", + "PRODUCT_CATALOG_SERVICE_ADDR": "productcatalogservice:3550" + }, + "expose": [ + "8080" + ] + }, + "redis-cart": { + "environment": {}, + "expose": [ + "6379" + ] + }, + "shippingservice": { + "environment": { + "DISABLE_PROFILER": "1", + "PORT": "50051" + }, + "expose": [ + "50051" + ] + } + }, + "sources": [ + "https://github.com/DimitryZH/application-modernization-lab/blob/9960484b5c7f1568688fa6fc8eb256976d6c18a4/workloads/online-shop/README.md", + "https://github.com/DimitryZH/application-modernization-lab/blob/9960484b5c7f1568688fa6fc8eb256976d6c18a4/workloads/online-shop/kubernetes/online-shop.yaml", + "https://github.com/DimitryZH/application-modernization-lab/blob/9960484b5c7f1568688fa6fc8eb256976d6c18a4/workloads/online-shop/images.lock.json", + "https://github.com/DimitryZH/application-modernization-lab/blob/9960484b5c7f1568688fa6fc8eb256976d6c18a4/workloads/online-shop/UPSTREAM.md" + ] +} diff --git a/tests/test_compose.py b/tests/test_compose.py new file mode 100644 index 0000000..c0d82b7 --- /dev/null +++ b/tests/test_compose.py @@ -0,0 +1,175 @@ +"""Offline rendering checks; all Git repositories and outputs are temporary.""" + +import copy +import io +import json +from pathlib import Path +from contextlib import redirect_stdout +import unittest +from unittest.mock import patch + +import test_validation as fixtures +from container_platform import compose + + +class ComposeTests(unittest.TestCase): + def setUp(self): + self.fixture = fixtures.ValidationTests() + self.fixture.setUp() + self.addCleanup(self.fixture.doCleanups) + self.repo = self.fixture.repo + self.inventory = json.loads((fixtures.ROOT / "inventories/online-shop-sre-dev-v1.json").read_text()) + self.candidate = json.loads((fixtures.ROOT / "releases/baseline-2026-10-18.json").read_text()) + self.pin_inventory() + self.input = self.fixture.input + self.output = Path(self.fixture.temp.name) / "compose.json" + + def pin_inventory(self): + self.candidate["inventory"] = {"path": fixtures.INVENTORY_PATH, + "revision": self.fixture.commit_inventory(self.inventory)} + + def render(self, inspection=True, **kwargs): + self.input.write_text(json.dumps(self.candidate), encoding="utf-8") + return compose.render(self.input, self.repo, compose.PROFILE, self.output, + inspection_only=inspection, **kwargs) + + def test_deterministic_exact_mapping_images_and_safe_topology(self): + result = self.render() + self.assertEqual(result["exitCode"], 0) + self.assertEqual(result["validation"]["exitCode"], 1) + self.assertEqual([d["code"] for d in result["validation"]["checks"][-1]["diagnostics"]], ["legacy_ineligible"] * 12) + original = self.output.read_bytes() + self.candidate["components"].reverse() + self.assertEqual(self.render(overwrite=True)["exitCode"], 0) + self.assertEqual(self.output.read_bytes(), original) + document = json.loads(original) + services = document["services"] + self.assertEqual(set(services), compose.IDS) + for component in self.candidate["components"]: + service = services[component["id"]] + self.assertEqual(service["image"], component["image"]["repository"] + "@" + component["image"]["digest"]) + self.assertEqual(service["platform"], "linux/amd64") + self.assertFalse(set(service) & {"build", "volumes", "privileged", "network_mode", "container_name", "command", "entrypoint", "healthcheck"}) + if component["id"] != "frontend": + self.assertNotIn("ports", service) + self.assertEqual(services["frontend"]["ports"], [{"host_ip": "127.0.0.1", "published": "8080", "target": 8080, "protocol": "tcp"}]) + self.assertEqual(services["redis-cart"]["tmpfs"], ["/data"]) + self.assertEqual(services["loadgenerator"]["environment"]["FRONTEND_ADDR"], "frontend:8080") + self.assertEqual(services["checkoutservice"]["environment"]["EMAIL_SERVICE_ADDR"], "emailservice:8080") + self.assertEqual(services["frontend"]["environment"]["ENABLE_ASSISTANT"], "false") + self.assertNotIn("${", original.decode()) + self.assertNotIn(str(self.repo), original.decode()) + self.assertTrue(document["x-container-platform"]["inspectionOnly"]) + + def test_candidate_digest_is_the_only_image_source(self): + self.candidate["components"][0]["image"]["digest"] = "sha256:" + "b" * 64 + self.assertEqual(self.render()["exitCode"], 0) + self.assertEqual(json.loads(self.output.read_bytes())["services"][self.candidate["components"][0]["id"]]["image"].split("@")[1], "sha256:" + "b" * 64) + self.assertNotIn("sha256:", json.dumps(compose.load_profile(compose.PROFILE))) + + def test_ordinary_render_requires_traceability(self): + result = self.render(inspection=False) + self.assertEqual(result["exitCode"], 1) + self.assertFalse(self.output.exists()) + for c in self.candidate["components"]: + c["traceability"].update(status="upstream", reason=None, upstream="https://example.invalid/publisher") + result = self.render(inspection=False) + self.assertEqual(result["exitCode"], 0) + self.assertEqual(result["validation"]["exitCode"], 0) + self.assertFalse(result["inspectionOnly"]) + + def test_inspection_does_not_bypass_other_failures(self): + mutations = [lambda c: c.update(purpose="example", notice="NON-DEPLOYABLE SYNTHETIC EXAMPLE"), + lambda c: c.update(schemaVersion=2), + lambda c: c["components"].pop(), + lambda c: c["components"][0]["image"].update(repository="docker.io/other/disallowed"), + lambda c: c["inventory"].update(revision="f" * 40), + lambda c: c["components"][0].update(command="untrusted")] + original = copy.deepcopy(self.candidate) + self.output.write_bytes(b"preserve existing output") + for mutation in mutations: + with self.subTest(mutation=mutation): + self.candidate = copy.deepcopy(original) + mutation(self.candidate) + self.assertNotEqual(self.render(overwrite=True)["exitCode"], 0) + self.assertEqual(self.output.read_bytes(), b"preserve existing output") + self.output.unlink() + self.assertNotEqual(self.render()["exitCode"], 0) + self.assertFalse(self.output.exists()) + + def test_invalid_inventory_and_profile_membership_platform(self): + self.inventory["components"][0]["required"] = False + self.pin_inventory() + self.assertNotEqual(self.render()["exitCode"], 0) + self.inventory["components"][0]["required"] = True + self.inventory["profile"] = "other-profile" + self.pin_inventory() + self.assertIn("profile_membership_mismatch", self.render()["diagnostics"]) + self.inventory["profile"] = "sre-dev-online-shop-v1" + self.inventory["targetPlatforms"] = ["linux/arm64"] + for entry in self.inventory["components"]: + entry["targetPlatforms"] = ["linux/arm64"] + for c in self.candidate["components"]: + c["image"]["platforms"] = ["linux/arm64"] + self.pin_inventory() + self.assertIn("profile_membership_mismatch", self.render()["diagnostics"]) + self.assertFalse(self.output.exists()) + + def test_valid_but_different_inventory_membership_is_not_this_profile(self): + identity = self.inventory["components"][0]["id"] + self.inventory["components"][0]["id"] = "other-service" + for component in self.candidate["components"]: + if component["id"] == identity: + component["id"] = "other-service" + self.pin_inventory() + result = self.render() + self.assertEqual(result["validation"]["checks"][-2]["status"], "pass") + self.assertEqual(result["diagnostics"], ["profile_membership_mismatch"]) + self.assertFalse(self.output.exists()) + + def test_strict_parse_and_unsupported_profile(self): + for raw in [b"{", b'{"schemaVersion":1,"schemaVersion":1}', b'\xff']: + self.input.write_bytes(raw) + result = compose.render(self.input, self.repo, compose.PROFILE, self.output, inspection_only=True) + self.assertNotEqual(result["exitCode"], 0) + self.assertFalse(self.output.exists()) + result = compose.render(self.input, self.repo, "../untrusted", self.output) + self.assertEqual(result["diagnostics"], ["profile_unsupported"]) + + def test_invalid_configuration_mapping_rejected(self): + profile = compose.load_profile(compose.PROFILE) + profile["services"]["loadgenerator"]["environment"]["FRONTEND_ADDR"] = "frontend:80" + with patch.object(compose, "strict_json", return_value=profile): + with self.assertRaises(compose.Failure): + compose.load_profile(compose.PROFILE) + + def test_overwrite_input_protection_and_write_failure(self): + self.assertEqual(self.render()["exitCode"], 0) + original = self.output.read_bytes() + self.assertEqual(self.render()["diagnostics"], ["output_exists"]) + self.assertEqual(self.output.read_bytes(), original) + self.assertEqual(self.render(overwrite=True)["exitCode"], 0) + for target in [self.input, self.repo / fixtures.INVENTORY_PATH]: + before = target.read_bytes() + result = compose.render(self.input, self.repo, compose.PROFILE, target, inspection_only=True, overwrite=True) + self.assertEqual(result["diagnostics"], ["output_is_input"]) + self.assertEqual(target.read_bytes(), before) + result = compose.render(self.input, self.repo, compose.PROFILE, self.output / "missing" / "config.json", inspection_only=True) + self.assertEqual(result["exitCode"], 2) + self.assertEqual(result["diagnostics"], ["output_write_failed"]) + + def test_cli_reports_separate_from_compose_document(self): + self.input.write_text(json.dumps(self.candidate)) + output = io.StringIO() + with redirect_stdout(output): + code = compose.main([str(self.input), "--repository", str(self.repo), "--profile", compose.PROFILE, + "--output", str(self.output), "--inspection-only"]) + self.assertEqual(code, 0) + report = json.loads(output.getvalue()) + self.assertEqual(report["validation"]["exitCode"], 1) + self.assertNotIn("services", report) + self.assertIn("services", json.loads(self.output.read_bytes())) + + +if __name__ == "__main__": + unittest.main()