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:
@@ -583,3 +583,15 @@ also closes P1-1 (adapter hardcoded defaults, deferred from v1.2).
|
||||
- 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.
|
||||
- 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)
|
||||
#
|
||||
# Reference example for an ECS Fargate microservice deployment.
|
||||
# This contract declares only the inputs the composition wires reference
|
||||
# (bucket_name, region) plus a representative image/port.
|
||||
# Interpolation (D-081): bucket_name uses 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
|
||||
module: microservice
|
||||
environment: dev
|
||||
inputs:
|
||||
bucket_name: acdl-microservice-demo
|
||||
region: us-east-1
|
||||
bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}
|
||||
region: ${env.region}
|
||||
image: public.ecr.aws/docker/library/nginx:latest
|
||||
port: 80
|
||||
@@ -8,10 +8,16 @@
|
||||
#
|
||||
# Validated against schemas/contract.schema.json.
|
||||
# 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
|
||||
module: static-assets
|
||||
environment: dev
|
||||
inputs:
|
||||
bucket_name: acdl-spike-bucket
|
||||
region: us-east-1
|
||||
bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}
|
||||
region: ${env.region}
|
||||
@@ -20,12 +20,34 @@ CLI: contract_resolver.py <contract.yaml> <out.json>
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
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):
|
||||
with open(path, "r") as fh:
|
||||
return json.load(fh)
|
||||
@@ -36,6 +58,51 @@ def _load_yaml(path):
|
||||
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):
|
||||
"""Resolve a wire 'from' reference to a concrete value.
|
||||
|
||||
@@ -323,12 +390,16 @@ def decommission_transform(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.
|
||||
|
||||
Args:
|
||||
contract_path: Path to the contract YAML 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:
|
||||
A dict representing the Target Stack instance.
|
||||
@@ -339,12 +410,30 @@ def resolve(contract_path, repo_root=None):
|
||||
# Load contract
|
||||
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
|
||||
contract_schema = _load_json(os.path.join(repo_root, "schemas", "contract.schema.json"))
|
||||
|
||||
# Validate contract against 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
|
||||
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 --env dev
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -32,6 +33,27 @@ def _contract_environment(contract_path):
|
||||
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):
|
||||
return (
|
||||
"=== ACDL Environment Onboarding ===\n"
|
||||
|
||||
@@ -10,7 +10,17 @@ runner key — the platform manages all of that here.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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))
|
||||
s3_res = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"]
|
||||
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"
|
||||
|
||||
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