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:
+98
-4
@@ -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
|
During the Nova rebrand transition window (P2–P4), `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 (P2–P4), `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,
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user