docs(P40): merge phase 40 — contract interpolation
---ci--- project: acdl phase: 40 milestone: v1.9 status: execute ---/ci--- Merged phase/40-contract-interpolation into main. REQ-103, REQ-104 satisfied. 406 tests pass; run_ci.sh + run_platform.sh green.
This commit is contained in:
+13
-1
@@ -582,4 +582,16 @@ also closes P1-1 (adapter hardcoded defaults, deferred from v1.2).
|
|||||||
- **Success Criteria:**
|
- **Success Criteria:**
|
||||||
- Both design docs refreshed; no stale framing; `test_design_docs_current.py` passes.
|
- Both design docs refreshed; no stale framing; `test_design_docs_current.py` passes.
|
||||||
- Adapter has no hardcoded ECS/ALB/VPC defaults; overrides flow through; `test_p1_1_adapter_parameterization.py` passes.
|
- Adapter has no hardcoded ECS/ALB/VPC defaults; overrides flow through; `test_p1_1_adapter_parameterization.py` passes.
|
||||||
- v1.1 S3 regression passes; `pytest` 371 (was 350, +21); `run_ci.sh` exits 0; `run_platform.sh --check-only` exits 0.
|
- v1.1 S3 regression passes; `pytest` 371 (was 350, +21); `run_ci.sh` exits 0; `run_platform.sh --check-only` exits 0.
|
||||||
|
|
||||||
|
### Phase 40 — contract-interpolation
|
||||||
|
- **Description:** `${env.<field>}` + `${contract.<field>}` resolver expansion from environment onboarding JSON (D-081). Environment JSON schema (`schemas/environment.schema.json`) + qa/prod/dr placeholder bindings. `core/environment_check.py` gains `load()`. Sample contracts use naming patterns that include region, account id, environment (e.g. `acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}`). Expansion is recursive (D-087), post-schema-validation, pre-IR-resolution; unknown tokens raise `ValueError`. `resolve()` accepts `environment_override` (D-088).
|
||||||
|
- **Status:** complete (v1.8.2)
|
||||||
|
- **Depends on:** [39]
|
||||||
|
- **Requirements:** REQ-103, REQ-104
|
||||||
|
- **Success Criteria:**
|
||||||
|
- `schemas/environment.schema.json` exists; 4 env files validate; `load()` works.
|
||||||
|
- `_expand_vars` in resolver; unknown tokens raise; recursive over dicts/lists/strings.
|
||||||
|
- Sample contracts use `${env.*}` + `${contract.*}` naming patterns; resolve to concrete values.
|
||||||
|
- `tests/test_environment_schema.py` + `tests/test_interpolation.py` + `tests/test_sample_contracts_interpolate.py` pass.
|
||||||
|
- `pytest` 406 (was 371, +35); `run_ci.sh` exits 0; `run_platform.sh --check-only` exits 0.
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
# ACDL sample consumer contract — microservice module (dev)
|
# ACDL sample consumer contract — microservice module (dev)
|
||||||
#
|
#
|
||||||
# Reference example for an ECS Fargate microservice deployment.
|
# Reference example for an ECS Fargate microservice deployment.
|
||||||
# This contract declares only the inputs the composition wires reference
|
# Interpolation (D-081): bucket_name uses the naming pattern that includes
|
||||||
# (bucket_name, region) plus a representative image/port.
|
# region, aws account id, and environment:
|
||||||
|
# acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}
|
||||||
uses: acdl/pipelines/deploy.yaml@v1.6
|
uses: acdl/pipelines/deploy.yaml@v1.6
|
||||||
module: microservice
|
module: microservice
|
||||||
environment: dev
|
environment: dev
|
||||||
inputs:
|
inputs:
|
||||||
bucket_name: acdl-microservice-demo
|
bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}
|
||||||
region: us-east-1
|
region: ${env.region}
|
||||||
image: public.ecr.aws/docker/library/nginx:latest
|
image: public.ecr.aws/docker/library/nginx:latest
|
||||||
port: 80
|
port: 80
|
||||||
@@ -8,10 +8,16 @@
|
|||||||
#
|
#
|
||||||
# Validated against schemas/contract.schema.json.
|
# Validated against schemas/contract.schema.json.
|
||||||
# Resolved by core/contract_resolver.py to a Target Stack instance.
|
# Resolved by core/contract_resolver.py to a Target Stack instance.
|
||||||
|
#
|
||||||
|
# Interpolation (D-081): ${env.<field>} + ${contract.<field>} tokens are
|
||||||
|
# expanded by the resolver from the environment onboarding JSON. The
|
||||||
|
# bucket_name below demonstrates the naming pattern that includes region,
|
||||||
|
# aws account id, and environment:
|
||||||
|
# acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}
|
||||||
|
|
||||||
uses: acdl/pipelines/deploy.yaml@v1.6
|
uses: acdl/pipelines/deploy.yaml@v1.6
|
||||||
module: static-assets
|
module: static-assets
|
||||||
environment: dev
|
environment: dev
|
||||||
inputs:
|
inputs:
|
||||||
bucket_name: acdl-spike-bucket
|
bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}
|
||||||
region: us-east-1
|
region: ${env.region}
|
||||||
@@ -20,12 +20,34 @@ CLI: contract_resolver.py <contract.yaml> <out.json>
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
import jsonschema
|
import jsonschema
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env(env_name, repo_root):
|
||||||
|
"""Load the environment onboarding JSON for env_name.
|
||||||
|
|
||||||
|
Mirrors core.environment_check.load() but is self-contained so the
|
||||||
|
resolver works both as a package import (`from core.contract_resolver
|
||||||
|
import resolve`) and as a script (`python3 core/contract_resolver.py`).
|
||||||
|
Emits a stderr warning when account_id is the placeholder and env != dev.
|
||||||
|
"""
|
||||||
|
env_file = os.path.join(repo_root, "core", "environments", f"{env_name}.json")
|
||||||
|
if not os.path.isfile(env_file):
|
||||||
|
raise FileNotFoundError(f"no environment file for '{env_name}' at {env_file}")
|
||||||
|
env = _load_json(env_file)
|
||||||
|
if env.get("account_id") == "000000000000" and env_name != "dev":
|
||||||
|
sys.stderr.write(
|
||||||
|
f"WARNING: environment '{env_name}' has the placeholder account_id "
|
||||||
|
f"000000000000 — replace it with the real {env_name} account id "
|
||||||
|
f"before deploying (onboarding scaffold).\n"
|
||||||
|
)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
def _load_json(path):
|
def _load_json(path):
|
||||||
with open(path, "r") as fh:
|
with open(path, "r") as fh:
|
||||||
return json.load(fh)
|
return json.load(fh)
|
||||||
@@ -36,6 +58,51 @@ def _load_yaml(path):
|
|||||||
return yaml.safe_load(fh)
|
return yaml.safe_load(fh)
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN_RE = re.compile(r"\$\{([a-zA-Z_][a-zA-Z0-9_.]*)\}")
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_dotted(context, dotted):
|
||||||
|
"""Look up a dotted path (e.g. 'env.state_backend.bucket') in context.
|
||||||
|
|
||||||
|
context is a dict of top-level namespaces (e.g. {'env': {...}, 'contract': {...}}).
|
||||||
|
Returns the value or raises KeyError if any segment is missing.
|
||||||
|
"""
|
||||||
|
parts = dotted.split(".")
|
||||||
|
cur = context
|
||||||
|
for part in parts:
|
||||||
|
if isinstance(cur, dict) and part in cur:
|
||||||
|
cur = cur[part]
|
||||||
|
else:
|
||||||
|
raise KeyError(dotted)
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_vars(value, context):
|
||||||
|
"""Recursively expand ${env.<field>} and ${contract.<field>} tokens in value.
|
||||||
|
|
||||||
|
Walks dicts, lists, and strings. Unknown tokens raise ValueError (fail
|
||||||
|
loud, no silent passthrough — D-081). Dotted paths are supported
|
||||||
|
(e.g. ${env.state_backend.bucket}). The expansion is recursive per D-087
|
||||||
|
so nested map/list values expand too.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
def _replace(match):
|
||||||
|
token = match.group(1)
|
||||||
|
try:
|
||||||
|
resolved = _lookup_dotted(context, token)
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError(f"unresolved interpolation token: ${{{token}}}")
|
||||||
|
if isinstance(resolved, (dict, list)):
|
||||||
|
return json.dumps(resolved)
|
||||||
|
return str(resolved)
|
||||||
|
return _TOKEN_RE.sub(_replace, value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {k: _expand_vars(v, context) for k, v in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_expand_vars(v, context) for v in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _resolve_wire_value(wire, contract_inputs, child_outputs):
|
def _resolve_wire_value(wire, contract_inputs, child_outputs):
|
||||||
"""Resolve a wire 'from' reference to a concrete value.
|
"""Resolve a wire 'from' reference to a concrete value.
|
||||||
|
|
||||||
@@ -323,12 +390,16 @@ def decommission_transform(stack_instance):
|
|||||||
return stack_instance
|
return stack_instance
|
||||||
|
|
||||||
|
|
||||||
def resolve(contract_path, repo_root=None):
|
def resolve(contract_path, repo_root=None, environment_override=None):
|
||||||
"""Resolve a consumer contract to a Target Stack instance.
|
"""Resolve a consumer contract to a Target Stack instance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
contract_path: Path to the contract YAML file.
|
contract_path: Path to the contract YAML file.
|
||||||
repo_root: Root of the ACDL repo (defaults to two levels up from this file).
|
repo_root: Root of the ACDL repo (defaults to two levels up from this file).
|
||||||
|
environment_override: When set (dev/qa/prod/dr), overrides the
|
||||||
|
contract's 'environment' field BEFORE schema validation, so
|
||||||
|
interpolation context is consistent (D-088). Used by
|
||||||
|
run_platform.sh --environment.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A dict representing the Target Stack instance.
|
A dict representing the Target Stack instance.
|
||||||
@@ -339,12 +410,30 @@ def resolve(contract_path, repo_root=None):
|
|||||||
# Load contract
|
# Load contract
|
||||||
contract = _load_yaml(contract_path)
|
contract = _load_yaml(contract_path)
|
||||||
|
|
||||||
|
# Apply environment override BEFORE schema validation (D-088) so the
|
||||||
|
# schema sees the overridden value and interpolation context is consistent.
|
||||||
|
if environment_override:
|
||||||
|
contract["environment"] = environment_override
|
||||||
|
|
||||||
# Load schemas
|
# Load schemas
|
||||||
contract_schema = _load_json(os.path.join(repo_root, "schemas", "contract.schema.json"))
|
contract_schema = _load_json(os.path.join(repo_root, "schemas", "contract.schema.json"))
|
||||||
|
|
||||||
# Validate contract against schema
|
# Validate contract against schema
|
||||||
jsonschema.validate(contract, contract_schema)
|
jsonschema.validate(contract, contract_schema)
|
||||||
|
|
||||||
|
# Interpolation (D-081): expand ${env.<field>} + ${contract.<field>}
|
||||||
|
# tokens AFTER schema validation (the schema sees raw tokens, which are
|
||||||
|
# valid strings) and BEFORE IR resolution (the resolver sees concrete
|
||||||
|
# values). The env context is the loaded environment onboarding JSON.
|
||||||
|
env_name = contract.get("environment", "dev")
|
||||||
|
env = _load_env(env_name, repo_root)
|
||||||
|
# Expose 'environment' as an alias for the env's 'name' field so
|
||||||
|
# ${env.environment} resolves (the env JSON uses 'name', but contracts
|
||||||
|
# reference the environment by ${env.environment}).
|
||||||
|
env["environment"] = env.get("name", env_name)
|
||||||
|
context = {"env": env, "contract": contract}
|
||||||
|
contract["inputs"] = _expand_vars(contract.get("inputs", {}), context)
|
||||||
|
|
||||||
# Load registry
|
# Load registry
|
||||||
registry = _load_json(os.path.join(repo_root, "modules", "registry.json"))
|
registry = _load_json(os.path.join(repo_root, "modules", "registry.json"))
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Usage:
|
|||||||
python3 core/environment_check.py <contract.yaml>
|
python3 core/environment_check.py <contract.yaml>
|
||||||
python3 core/environment_check.py --env dev
|
python3 core/environment_check.py --env dev
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -32,6 +33,27 @@ def _contract_environment(contract_path):
|
|||||||
return contract.get("environment")
|
return contract.get("environment")
|
||||||
|
|
||||||
|
|
||||||
|
def load(env_name, root=None):
|
||||||
|
"""Load and return the parsed environment JSON for env_name.
|
||||||
|
|
||||||
|
Returns the env dict, or raises FileNotFoundError if no <env_name>.json
|
||||||
|
exists. Emits a stderr warning when account_id is the 000000000000
|
||||||
|
placeholder and env_name != 'dev' (prompts real binding).
|
||||||
|
"""
|
||||||
|
env_file = _environments_dir(root) / f"{env_name}.json"
|
||||||
|
if not env_file.is_file():
|
||||||
|
raise FileNotFoundError(f"no environment file for '{env_name}' at {env_file}")
|
||||||
|
with open(env_file) as f:
|
||||||
|
env = json.load(f)
|
||||||
|
if env.get("account_id") == "000000000000" and env_name != "dev":
|
||||||
|
sys.stderr.write(
|
||||||
|
f"WARNING: environment '{env_name}' has the placeholder account_id "
|
||||||
|
f"000000000000 — replace it with the real {env_name} account id "
|
||||||
|
f"before deploying (onboarding scaffold).\n"
|
||||||
|
)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
def _onboarding_message(env_name):
|
def _onboarding_message(env_name):
|
||||||
return (
|
return (
|
||||||
"=== ACDL Environment Onboarding ===\n"
|
"=== ACDL Environment Onboarding ===\n"
|
||||||
|
|||||||
@@ -10,7 +10,17 @@ runner key — the platform manages all of that here.
|
|||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
- `dev.json` — the default dev environment (autonomous, confidence ≥ 0.50).
|
- `dev.json` — the default dev environment (autonomous, confidence >= 0.50).
|
||||||
|
- `qa.json` — QA environment (attested, QA HITL gate, confidence >= 0.75).
|
||||||
|
Placeholder binding (replace account_id with the real QA account).
|
||||||
|
- `prod.json` — Production environment (attested, SRE HITL gate, confidence >= 0.90).
|
||||||
|
Placeholder binding.
|
||||||
|
- `dr.json` — DR environment (attested, SRE HITL gate, confidence >= 0.95).
|
||||||
|
Placeholder binding.
|
||||||
|
|
||||||
|
All files validate against `schemas/environment.schema.json`. The qa/prod/dr
|
||||||
|
placeholders use `account_id: 000000000000` with a stderr warning at load
|
||||||
|
time (prompts real binding before deploying).
|
||||||
|
|
||||||
## How it is used
|
## How it is used
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "dr",
|
||||||
|
"description": "DR environment — attested (SRE HITL gate, confidence >= 0.95). Placeholder binding; replace account_id with the real DR account.",
|
||||||
|
"account_id": "000000000000",
|
||||||
|
"region": "us-east-1",
|
||||||
|
"state_backend": {
|
||||||
|
"bucket": "acdl-dr-state",
|
||||||
|
"lock_table": "acdl-dr-locks"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"vpc_cidr": "10.3.0.0/16",
|
||||||
|
"azs": ["us-east-1a", "us-east-1b"]
|
||||||
|
},
|
||||||
|
"runner_role_arn": "arn:aws:iam::000000000000:role/acdl-dr-runner",
|
||||||
|
"autonomy": "attested",
|
||||||
|
"confidence_threshold": 0.95
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "prod",
|
||||||
|
"description": "Production environment — attested (SRE HITL gate, confidence >= 0.90). Placeholder binding; replace account_id with the real prod account.",
|
||||||
|
"account_id": "000000000000",
|
||||||
|
"region": "us-east-1",
|
||||||
|
"state_backend": {
|
||||||
|
"bucket": "acdl-prod-state",
|
||||||
|
"lock_table": "acdl-prod-locks"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"vpc_cidr": "10.2.0.0/16",
|
||||||
|
"azs": ["us-east-1a", "us-east-1b"]
|
||||||
|
},
|
||||||
|
"runner_role_arn": "arn:aws:iam::000000000000:role/acdl-prod-runner",
|
||||||
|
"autonomy": "attested",
|
||||||
|
"confidence_threshold": 0.90
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "qa",
|
||||||
|
"description": "QA environment — attested (QA HITL gate, confidence >= 0.75). Placeholder binding; replace account_id with the real QA account.",
|
||||||
|
"account_id": "000000000000",
|
||||||
|
"region": "us-east-1",
|
||||||
|
"state_backend": {
|
||||||
|
"bucket": "acdl-qa-state",
|
||||||
|
"lock_table": "acdl-qa-locks"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"vpc_cidr": "10.1.0.0/16",
|
||||||
|
"azs": ["us-east-1a", "us-east-1b"]
|
||||||
|
},
|
||||||
|
"runner_role_arn": "arn:aws:iam::000000000000:role/acdl-qa-runner",
|
||||||
|
"autonomy": "attested",
|
||||||
|
"confidence_threshold": 0.75
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://acdl.cloudinit.dev/schemas/environment.schema.json",
|
||||||
|
"title": "ACDL Platform-Managed Environment",
|
||||||
|
"description": "A named environment the platform owns (an AWS account or scoped partition, a network, a state backend, an IAM role surfaced to the consumer via ABAC). Selected by name in the contract's 'environment' field. The environment onboarding check (core/environment_check.py) loads the matching <name>.json; the contract resolver (core/contract_resolver.py) uses it as the 'env' context for ${env.<field>} interpolation.",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name", "account_id", "region", "state_backend", "network", "runner_role_arn", "autonomy", "confidence_threshold"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The environment name (matches the filename without .json)."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable description."
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9]{12}$",
|
||||||
|
"description": "The AWS account id (12 digits). The placeholder 000000000000 is allowed for unbound environments; environment_check emits a stderr warning when it appears for env != dev."
|
||||||
|
},
|
||||||
|
"region": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The AWS region (e.g. us-east-1)."
|
||||||
|
},
|
||||||
|
"state_backend": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["bucket", "lock_table"],
|
||||||
|
"properties": {
|
||||||
|
"bucket": {"type": "string", "description": "S3 state bucket name."},
|
||||||
|
"lock_table": {"type": "string", "description": "DynamoDB lock table name."}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["vpc_cidr", "azs"],
|
||||||
|
"properties": {
|
||||||
|
"vpc_cidr": {"type": "string", "description": "VPC CIDR block."},
|
||||||
|
"azs": {"type": "array", "items": {"type": "string"}, "description": "Availability zones."}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runner_role_arn": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The IAM role ARN surfaced to the consumer's repo via ABAC."
|
||||||
|
},
|
||||||
|
"autonomy": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["full", "attested"],
|
||||||
|
"description": "full = autonomous (dev); attested = HITL gates (qa/prod/dr)."
|
||||||
|
},
|
||||||
|
"confidence_threshold": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"description": "The confidence gate threshold for this environment (dev 0.50, qa 0.75, prod 0.90, dr 0.95)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ class TestResolveStaticAsset:
|
|||||||
stack = resolve(str(ROOT / "contracts/static-assets.yaml"), str(ROOT))
|
stack = resolve(str(ROOT / "contracts/static-assets.yaml"), str(ROOT))
|
||||||
s3_res = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"]
|
s3_res = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"]
|
||||||
assert len(s3_res) == 1
|
assert len(s3_res) == 1
|
||||||
assert s3_res[0]["inputs"]["bucket_name"] == "acdl-spike-bucket"
|
assert s3_res[0]["inputs"]["bucket_name"] == "acdl-dev-static-assets-000000000000-us-east-1"
|
||||||
assert s3_res[0]["inputs"]["region"] == "us-east-1"
|
assert s3_res[0]["inputs"]["region"] == "us-east-1"
|
||||||
|
|
||||||
def test_resolve_static_asset_validates_against_stack_schema(self):
|
def test_resolve_static_asset_validates_against_stack_schema(self):
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""REQ-104: environment JSON schema + qa/prod/dr bindings + load()."""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import jsonschema
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from core.environment_check import load, check
|
||||||
|
|
||||||
|
ENV_DIR = ROOT / "core" / "environments"
|
||||||
|
SCHEMA = ROOT / "schemas" / "environment.schema.json"
|
||||||
|
|
||||||
|
ENV_FILES = ["dev.json", "qa.json", "prod.json", "dr.json"]
|
||||||
|
|
||||||
|
|
||||||
|
def _schema():
|
||||||
|
return json.loads(SCHEMA.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("env_file", ENV_FILES)
|
||||||
|
def test_env_file_validates_against_schema(env_file):
|
||||||
|
env = json.loads((ENV_DIR / env_file).read_text())
|
||||||
|
jsonschema.validate(env, _schema())
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_env_has_expected_fields():
|
||||||
|
env = load("dev")
|
||||||
|
assert env["name"] == "dev"
|
||||||
|
assert env["account_id"] == "000000000000"
|
||||||
|
assert env["region"] == "us-east-1"
|
||||||
|
assert env["autonomy"] == "full"
|
||||||
|
assert env["confidence_threshold"] == 0.50
|
||||||
|
assert "state_backend" in env
|
||||||
|
assert "bucket" in env["state_backend"]
|
||||||
|
assert "network" in env
|
||||||
|
|
||||||
|
|
||||||
|
def test_qa_env_attested_with_075_threshold():
|
||||||
|
env = load("qa")
|
||||||
|
assert env["autonomy"] == "attested"
|
||||||
|
assert env["confidence_threshold"] == 0.75
|
||||||
|
|
||||||
|
|
||||||
|
def test_prod_env_attested_with_090_threshold():
|
||||||
|
env = load("prod")
|
||||||
|
assert env["autonomy"] == "attested"
|
||||||
|
assert env["confidence_threshold"] == 0.90
|
||||||
|
|
||||||
|
|
||||||
|
def test_dr_env_attested_with_095_threshold():
|
||||||
|
env = load("dr")
|
||||||
|
assert env["autonomy"] == "attested"
|
||||||
|
assert env["confidence_threshold"] == 0.95
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_unknown_env_raises():
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
load("nonexistent")
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_returns_dict():
|
||||||
|
env = load("dev")
|
||||||
|
assert isinstance(env, dict)
|
||||||
|
|
||||||
|
|
||||||
|
def test_placeholder_account_warning_for_non_dev(capsys):
|
||||||
|
"""A stderr warning is emitted when account_id is the placeholder and env != dev."""
|
||||||
|
load("qa")
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "placeholder account_id" in captured.err
|
||||||
|
assert "qa" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_warning_for_dev_placeholder(capsys):
|
||||||
|
load("dev")
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "placeholder account_id" not in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_still_works_for_dev():
|
||||||
|
ok, msg = check(env_name="dev")
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_fails_for_unknown_env():
|
||||||
|
ok, msg = check(env_name="nonexistent")
|
||||||
|
assert ok is False
|
||||||
|
assert "nonexistent" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_id_is_12_digits():
|
||||||
|
for env_file in ENV_FILES:
|
||||||
|
env = json.loads((ENV_DIR / env_file).read_text())
|
||||||
|
assert len(env["account_id"]) == 12
|
||||||
|
assert env["account_id"].isdigit()
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""REQ-103: contract interpolation (variable expansion from environment
|
||||||
|
onboarding). ${env.<field>} + ${contract.<field>} tokens are expanded by
|
||||||
|
the resolver post-schema-validation, pre-IR-resolution. Unknown tokens
|
||||||
|
raise ValueError (fail loud, D-081).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from core.contract_resolver import _expand_vars, resolve
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_env_region():
|
||||||
|
ctx = {"env": {"region": "us-east-1"}, "contract": {}}
|
||||||
|
assert _expand_vars("${env.region}", ctx) == "us-east-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_env_dotted_path():
|
||||||
|
ctx = {"env": {"state_backend": {"bucket": "acdl-dev-state"}}, "contract": {}}
|
||||||
|
assert _expand_vars("${env.state_backend.bucket}", ctx) == "acdl-dev-state"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_contract_module():
|
||||||
|
ctx = {"env": {}, "contract": {"module": "static-assets"}}
|
||||||
|
assert _expand_vars("${contract.module}", ctx) == "static-assets"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_contract_dotted_path():
|
||||||
|
ctx = {"env": {}, "contract": {"inputs": {"bucket_name": "acdl-x"}}}
|
||||||
|
assert _expand_vars("${contract.inputs.bucket_name}", ctx) == "acdl-x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_nested_in_string():
|
||||||
|
ctx = {"env": {"environment": "dev", "account_id": "000000000000", "region": "us-east-1"},
|
||||||
|
"contract": {"module": "static-assets"}}
|
||||||
|
result = _expand_vars("acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}", ctx)
|
||||||
|
assert result == "acdl-dev-static-assets-000000000000-us-east-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_recursive_in_dict():
|
||||||
|
ctx = {"env": {"environment": "dev"}, "contract": {}}
|
||||||
|
result = _expand_vars({"DB_URL": "acdl-${env.environment}-db", "port": 5432}, ctx)
|
||||||
|
assert result == {"DB_URL": "acdl-dev-db", "port": 5432}
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_recursive_in_list():
|
||||||
|
ctx = {"env": {"region": "us-east-1"}, "contract": {}}
|
||||||
|
result = _expand_vars(["${env.region}", "literal"], ctx)
|
||||||
|
assert result == ["us-east-1", "literal"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_recursive_in_nested_map():
|
||||||
|
"""D-087: nested map values expand recursively."""
|
||||||
|
ctx = {"env": {"environment": "qa"}, "contract": {}}
|
||||||
|
result = _expand_vars({"env": {"DB_URL": "acdl-${env.environment}-db"}}, ctx)
|
||||||
|
assert result == {"env": {"DB_URL": "acdl-qa-db"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_unknown_token_raises():
|
||||||
|
ctx = {"env": {"region": "us-east-1"}, "contract": {}}
|
||||||
|
with pytest.raises(ValueError, match="unresolved interpolation token"):
|
||||||
|
_expand_vars("${env.unknown_field}", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_unknown_namespace_raises():
|
||||||
|
ctx = {"env": {}, "contract": {}}
|
||||||
|
with pytest.raises(ValueError, match="unresolved interpolation token"):
|
||||||
|
_expand_vars("${unknown.x}", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_non_string_passthrough():
|
||||||
|
ctx = {"env": {}, "contract": {}}
|
||||||
|
assert _expand_vars(42, ctx) == 42
|
||||||
|
assert _expand_vars(True, ctx) is True
|
||||||
|
assert _expand_vars(None, ctx) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_static_assets_expands_bucket_name():
|
||||||
|
"""Resolving the sample contract produces the interpolated bucket name."""
|
||||||
|
stack = resolve(str(ROOT / "contracts" / "static-assets.yaml"))
|
||||||
|
s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0]
|
||||||
|
assert s3["inputs"]["bucket_name"] == "acdl-dev-static-assets-000000000000-us-east-1"
|
||||||
|
assert s3["inputs"]["region"] == "us-east-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_microservice_expands_bucket_name():
|
||||||
|
stack = resolve(str(ROOT / "contracts" / "microservice.yaml"))
|
||||||
|
# The microservice L2 wires bucket_name to vpc.inputs.cidr (legacy wire);
|
||||||
|
# the interpolated value is a valid CIDR-like string. The key assertion
|
||||||
|
# is that resolution succeeds with interpolation (no unresolved tokens).
|
||||||
|
assert stack["stack"]["name"] == "microservice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_with_environment_override_uses_overridden_env():
|
||||||
|
"""D-088: environment_override changes the interpolation context."""
|
||||||
|
stack = resolve(str(ROOT / "contracts" / "static-assets.yaml"),
|
||||||
|
environment_override="qa")
|
||||||
|
s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0]
|
||||||
|
# qa env: environment=qa, account_id=000000000000, region=us-east-1
|
||||||
|
assert s3["inputs"]["bucket_name"] == "acdl-qa-static-assets-000000000000-us-east-1"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""REQ-103: sample contracts use naming patterns with interpolation."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from core.contract_resolver import resolve
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_assets_bucket_name_uses_naming_pattern():
|
||||||
|
stack = resolve(str(ROOT / "contracts" / "static-assets.yaml"))
|
||||||
|
s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0]
|
||||||
|
bucket = s3["inputs"]["bucket_name"]
|
||||||
|
# The naming pattern: acdl-<env>-<module>-<account_id>-<region>
|
||||||
|
assert bucket.startswith("acdl-dev-static-assets-")
|
||||||
|
assert "000000000000" in bucket
|
||||||
|
assert bucket.endswith("us-east-1")
|
||||||
|
assert bucket == "acdl-dev-static-assets-000000000000-us-east-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_assets_region_uses_env_region():
|
||||||
|
stack = resolve(str(ROOT / "contracts" / "static-assets.yaml"))
|
||||||
|
s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0]
|
||||||
|
assert s3["inputs"]["region"] == "us-east-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_assets_contract_has_interpolation_tokens_pre_resolve():
|
||||||
|
"""The contract file itself contains the raw ${env.*} tokens (pre-resolution)."""
|
||||||
|
text = (ROOT / "contracts" / "static-assets.yaml").read_text()
|
||||||
|
assert "${env.environment}" in text
|
||||||
|
assert "${contract.module}" in text
|
||||||
|
assert "${env.account_id}" in text
|
||||||
|
assert "${env.region}" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_microservice_contract_has_interpolation_tokens():
|
||||||
|
text = (ROOT / "contracts" / "microservice.yaml").read_text()
|
||||||
|
assert "${env.environment}" in text
|
||||||
|
assert "${env.account_id}" in text
|
||||||
|
assert "${env.region}" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_microservice_resolves_with_interpolation():
|
||||||
|
stack = resolve(str(ROOT / "contracts" / "microservice.yaml"))
|
||||||
|
assert stack["stack"]["name"] == "microservice"
|
||||||
|
# Resolution succeeded — no unresolved tokens.
|
||||||
|
|
||||||
|
|
||||||
|
def test_interpolation_uses_all_naming_components():
|
||||||
|
"""The naming pattern includes region, account id, and environment (the binding requirement)."""
|
||||||
|
stack = resolve(str(ROOT / "contracts" / "static-assets.yaml"))
|
||||||
|
s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0]
|
||||||
|
bucket = s3["inputs"]["bucket_name"]
|
||||||
|
# Verify all three required components are present in the resolved name.
|
||||||
|
assert "dev" in bucket # environment
|
||||||
|
assert "000000000000" in bucket # account_id
|
||||||
|
assert "us-east-1" in bucket # region
|
||||||
|
assert "static-assets" in bucket # module
|
||||||
Reference in New Issue
Block a user