From 3338ec162268fc3666d1190a4414287cf5adc1a6 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Wed, 19 Aug 2026 22:47:37 +0000 Subject: [PATCH] =?UTF-8?q?feat(P02):=20core/env.synthesize=5Flocal=5Fenv?= =?UTF-8?q?=20=E2=80=94=20local=20env=20synthesizer=20(REQ-330,=20backend-?= =?UTF-8?q?engineer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- project: acdl phase: 2 milestone: v1.28 status: execute persona: backend-engineer --- synthesize_local_env(contract_path, environment) reads a contract YAML and produces a purely synthetic local env dict (account_id=000000000000 placeholder, region='local', local state_backend, local network) that validates against schemas/environment.schema.json. Mirrors the shape of core/environments/*.json + core/onboarding.py:generate_env_file() (shape parity on the required env-binding keys). No cloud provisioning — purely synthetic for nova apply --local. tests/test_local_env.py: 13 tests (schema validation, region/account sentinels, env override, threshold per-env, shape parity, missing-file default). --- core/env.py | 102 +++++++++++++++++++++++++-- tests/test_local_env.py | 149 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 tests/test_local_env.py diff --git a/core/env.py b/core/env.py index c9999e1..e61512b 100644 --- a/core/env.py +++ b/core/env.py @@ -1,4 +1,4 @@ -"""Environment helper (D-108, REQ-159, REQ-164). +"""Environment helper (D-108, REQ-159, REQ-164, REQ-330). During the Nova rebrand transition window (P2–P4), `get_env` read `NOVA_*` preferred with the legacy `ACDL_*` name as the fallback. **P5 @@ -9,14 +9,27 @@ During the Nova rebrand transition window (P2–P4), `get_env` read `.env.secrets` shell export in `scripts/run_platform.sh` and the Python parser in `core/regression_verify.py`) were updated to NOVA-only in P5 (the G-106 dual-read contract was retired with the fallback). + +P2 (REQ-330): `synthesize_local_env(contract_path, environment)` produces +a purely synthetic local env dict (account_id placeholder, region +"local", no real AWS resources) from a contract YAML. Mirrors the shape +of core/environments/*.json (validates against +schemas/environment.schema.json) so `nova apply --local` can run the +contract resolver + Terraform adapter without provisioning cloud +resources. This is the local-tier counterpart of +core/onboarding.py:generate_env_file() (the request-path binding +generator). """ from __future__ import annotations import os -from typing import Optional +from pathlib import Path +from typing import Any, Dict, Optional -__all__ = ["get_env"] +import yaml + +__all__ = ["get_env", "synthesize_local_env"] def get_env(name: str, default: Optional[str] = None) -> Optional[str]: @@ -28,4 +41,85 @@ def get_env(name: str, default: Optional[str] = None) -> Optional[str]: val = os.environ.get(f"NOVA_{name}") if val: return val - return default \ No newline at end of file + return default + + +# Default confidence thresholds per environment name (mirrors the schema +# description: dev 0.50, qa 0.75, prod 0.90, dr 0.95). Used by +# synthesize_local_env so the synthetic env matches the real env semantics. +_DEFAULT_THRESHOLDS: Dict[str, float] = { + "dev": 0.50, + "qa": 0.75, + "prod": 0.90, + "dr": 0.95, +} + + +def synthesize_local_env( + contract_path: str, + environment: Optional[str] = None, +) -> Dict[str, Any]: + """Synthesize a local env dict from a contract YAML (REQ-330). + + Reads the contract YAML (``yaml.safe_load``), derives a placeholder + environment binding that ``nova apply --local`` can use WITHOUT + provisioning real AWS resources. The produced dict: + + - ``name`` — the environment name (from the arg or the contract's + ``environment`` field, defaulting to ``"dev"``). + - ``account_id`` — ``"000000000000"`` (the schema-allowed placeholder + for an unbound environment; real account id filled by the platform). + - ``region`` — ``"local"`` (the local-tier sentinel; never a real + AWS region). + - ``state_backend`` — ``{bucket: "local-tfstate", lock_table: + "local-locks"}`` (local state; LocalS3StateBackend rewrites the + terraform backend to ``backend "local"`` using the stack name as + the state path, so no S3 bucket is used). + - ``network`` — a local RFC1918 CIDR + a single fake AZ. + - ``runner_role_arn`` — a placeholder ARN for the local tier. + - ``autonomy`` — ``"full"`` (the local tier is autonomous). + - ``confidence_threshold`` — the per-env default (0.50 for dev). + + The dict mirrors the shape of ``core/environments/*.json`` and + validates against ``schemas/environment.schema.json``. No cloud + provisioning occurs — purely synthetic. + + Args: + contract_path: Path to the contract YAML file. + environment: Optional environment name override (defaults to the + contract's ``environment`` field, or ``"dev"``). + + Returns: + The synthetic local env dict. + """ + contract_path_obj = Path(contract_path) + contract: Dict[str, Any] = {} + if contract_path_obj.is_file(): + with open(contract_path_obj) as fh: + contract = yaml.safe_load(fh) or {} + + env_name = environment or contract.get("environment", "dev") + stack_name = contract.get("id", env_name) + threshold = _DEFAULT_THRESHOLDS.get(env_name, 0.50) + + return { + "name": env_name, + "description": ( + f"Synthetic local-tier environment for contract '{stack_name}' " + f"(environment={env_name}). No real AWS resources — generated " + f"by core.env.synthesize_local_env (REQ-330) for nova apply --local." + ), + "account_id": "000000000000", + "region": "local", + "state_backend": { + "bucket": "local-tfstate", + "lock_table": "local-locks", + }, + "network": { + "vpc_cidr": "10.250.0.0/16", + "azs": ["local-a"], + }, + "runner_role_arn": "arn:aws:iam::000000000000:role/local-runner", + "autonomy": "full", + "confidence_threshold": threshold, + } \ No newline at end of file diff --git a/tests/test_local_env.py b/tests/test_local_env.py new file mode 100644 index 0000000..c57d160 --- /dev/null +++ b/tests/test_local_env.py @@ -0,0 +1,149 @@ +"""REQ-330 tests: core.env.synthesize_local_env — local env synthesizer. + +Verifies the synthesizer: + - reads a contract YAML and produces a local env dict + - the dict mirrors the shape of core/environments/*.json (validates + against schemas/environment.schema.json) + - region is "local" + account_id is the placeholder (no real AWS) + - the environment override wins over the contract's environment field + - mirrors core/onboarding.py:generate_env_file() shape (same required keys) +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import jsonschema +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core.env import synthesize_local_env + + +REPO_ROOT = Path(__file__).resolve().parent.parent +ENV_SCHEMA_PATH = REPO_ROOT / "schemas" / "environment.schema.json" + + +@pytest.fixture +def env_schema(): + return json.loads(ENV_SCHEMA_PATH.read_text()) + + +@pytest.fixture +def sample_contract(tmp_path): + """A minimal contract YAML for the synthesizer to read.""" + contract = """ +id: msvc +name: microservice +environment: dev +infrastructure: + microservice: + version: "1.0.0" + inputs: + image: nginx:latest +""" + p = tmp_path / "contract.yml" + p.write_text(contract) + return p + + +class TestSynthesizeLocalEnv: + def test_returns_dict_with_required_keys(self, sample_contract, env_schema): + env = synthesize_local_env(str(sample_contract)) + assert isinstance(env, dict) + # The schema-required keys. + for key in ( + "name", "account_id", "region", "state_backend", + "network", "runner_role_arn", "autonomy", "confidence_threshold", + ): + assert key in env, f"missing required key: {key}" + + def test_validates_against_environment_schema(self, sample_contract, env_schema): + env = synthesize_local_env(str(sample_contract)) + jsonschema.validate(env, env_schema) # raises on invalid + + def test_region_is_local(self, sample_contract): + env = synthesize_local_env(str(sample_contract)) + assert env["region"] == "local", "region must be the local sentinel" + + def test_account_id_is_placeholder(self, sample_contract): + env = synthesize_local_env(str(sample_contract)) + assert env["account_id"] == "000000000000", ( + "account_id must be the placeholder (no real AWS account)" + ) + + def test_state_backend_is_local(self, sample_contract): + env = synthesize_local_env(str(sample_contract)) + sb = env["state_backend"] + assert sb["bucket"] == "local-tfstate" + assert sb["lock_table"] == "local-locks" + + def test_uses_contract_environment_by_default(self, sample_contract): + env = synthesize_local_env(str(sample_contract)) + assert env["name"] == "dev" # the contract's environment field + + def test_environment_override_wins(self, sample_contract): + env = synthesize_local_env(str(sample_contract), environment="qa") + assert env["name"] == "qa" + # qa threshold is 0.75 (per-env default) + assert env["confidence_threshold"] == 0.75 + + def test_confidence_threshold_per_env(self, sample_contract): + for env_name, expected in (("dev", 0.50), ("qa", 0.75), ("prod", 0.90), ("dr", 0.95)): + env = synthesize_local_env(str(sample_contract), environment=env_name) + assert env["confidence_threshold"] == expected, env_name + + def test_autonomy_is_full(self, sample_contract): + env = synthesize_local_env(str(sample_contract)) + assert env["autonomy"] == "full" # local tier is autonomous + + def test_no_real_aws_resources(self, sample_contract): + """The synthesizer must NOT reference real AWS resources — region + is 'local', the ARN uses the placeholder account, the bucket is local.""" + env = synthesize_local_env(str(sample_contract)) + assert "us-east-1" not in env["region"] + assert "000000000000" in env["runner_role_arn"] + assert "local" in env["state_backend"]["bucket"] + + def test_mirrors_onboarding_env_file_shape(self, sample_contract, env_schema): + """The synthesized env has the same core shape as + core/onboarding.py:generate_env_file() output — both carry the + schema-required environment-binding keys. (generate_env_file adds + ownerId/billingTag for the onboarding request path; the synthesizer + is the local-tier counterpart and omits those — no consumer binding.)""" + from core.onboarding import generate_env_file + request = { + "consumerRepo": "acdl/consumer-a", + "requestedEnvironment": "dev", + "ownerId": "team-a", + "billingTag": "cc-a", + } + onboarded = generate_env_file(request) + # The synthesizer output validates against the env schema. + synth = synthesize_local_env(str(sample_contract)) + jsonschema.validate(synth, env_schema) + # Both carry the schema-required environment-binding keys. + required = { + "name", "account_id", "region", "state_backend", + "network", "runner_role_arn", "autonomy", "confidence_threshold", + } + assert required <= set(onboarded.keys()), "onboarding output missing required keys" + assert required <= set(synth.keys()), "synthesizer output missing required keys" + # The synthesizer omits the onboarding-request-only keys. + assert "ownerId" not in synth + assert "billingTag" not in synth + + def test_missing_contract_file_defaults_to_dev(self, tmp_path): + """A non-existent contract path defaults to the dev env (no crash).""" + env = synthesize_local_env(str(tmp_path / "nonexistent.yml")) + assert env["name"] == "dev" + assert env["region"] == "local" + + def test_description_mentions_contract_id(self, sample_contract): + env = synthesize_local_env(str(sample_contract)) + assert "msvc" in env["description"], ( + "description should reference the contract id for traceability" + ) \ No newline at end of file