feat(P02): core/env.synthesize_local_env — local env synthesizer (REQ-330, backend-engineer)

---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).
This commit is contained in:
Jon Chery
2026-08-19 22:47:37 +00:00
parent eb4fade710
commit 3338ec1622
2 changed files with 247 additions and 4 deletions
+98 -4
View File
@@ -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 (P2P4), `get_env` read During the Nova rebrand transition window (P2P4), `get_env` read
`NOVA_*` preferred with the legacy `ACDL_*` name as the fallback. **P5 `NOVA_*` preferred with the legacy `ACDL_*` name as the fallback. **P5
@@ -9,14 +9,27 @@ During the Nova rebrand transition window (P2P4), `get_env` read
`.env.secrets` shell export in `scripts/run_platform.sh` and the Python `.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 parser in `core/regression_verify.py`) were updated to NOVA-only in P5
(the G-106 dual-read contract was retired with the fallback). (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 from __future__ import annotations
import os 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]: 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}") val = os.environ.get(f"NOVA_{name}")
if val: if val:
return val return val
return default 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,
}
+149
View File
@@ -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"
)