From 4e495e5648f0b5cf554fcbb15c4e07fa68305e31 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Wed, 22 Jul 2026 18:24:02 +0000 Subject: [PATCH] feat(P21): environments concept + onboarding scaffold (REQ-61) ---ci--- project: acdl phase: 21 milestone: v1.6 status: execute ---/ci--- Introduce platform-managed environments: a consumer does not provide an AWS account, VPC, subnet, S3 state bucket, or runner key. A named environment is a platform-owned bundle of account + network + state backend + IAM role (surfaced via ABAC), selected by name in the contract. Scaffold: - core/environments/dev.json (sample dev env definition) - core/environments/README.md (how envs are used + how to add one) - core/environment_check.py (reads a contract's environment field, looks up core/environments/.json, prints a friendly onboarding prompt when none exists, exits non-zero) - scripts/run_platform.sh: Step 0 calls environment_check.py before contract validation; a missing env halts the pipeline with the onboarding prompt - tests/test_environment_check.py: 12 tests (dev bound, missing env -> onboarding prompt, onboarding message lists provisions, contract paths, wire-in, check-only still passes) Tests: 166 pass (154 + 12 new). --- core/environment_check.py | 101 ++++++++++++++++++++++++++++++++ core/environments/README.md | 27 +++++++++ core/environments/dev.json | 17 ++++++ scripts/run_platform.sh | 13 ++++ tests/test_environment_check.py | 99 +++++++++++++++++++++++++++++++ 5 files changed, 257 insertions(+) create mode 100644 core/environment_check.py create mode 100644 core/environments/README.md create mode 100644 core/environments/dev.json create mode 100644 tests/test_environment_check.py diff --git a/core/environment_check.py b/core/environment_check.py new file mode 100644 index 0000000..eae55e3 --- /dev/null +++ b/core/environment_check.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Environment onboarding check. + +Reads a contract's `environment` field and looks up the matching +`core/environments/.json`. If no matching file exists, prints a +friendly onboarding prompt and exits non-zero, halting the pipeline before +any work is done. + +Usage: + python3 core/environment_check.py + python3 core/environment_check.py --env dev +""" +import json +import os +import sys +from pathlib import Path + +try: + import yaml +except ImportError: + sys.stderr.write("PyYAML is required (pip install pyyaml)\n") + sys.exit(2) + + +def _environments_dir(root=None): + if root is None: + root = Path(__file__).resolve().parent.parent + return Path(root) / "core" / "environments" + + +def _contract_environment(contract_path): + with open(contract_path) as f: + contract = yaml.safe_load(f) + return contract.get("environment") + + +def _onboarding_message(env_name): + return ( + "=== ACDL Environment Onboarding ===\n" + f"No environment named '{env_name}' is bound to this repository.\n\n" + "ACDL environments are platform-managed. The platform provisions on\n" + "your behalf:\n" + " - an AWS account (or a scoped partition of one)\n" + " - a network (VPC + subnets)\n" + " - a state backend (an S3 bucket + DynamoDB lock table)\n" + " - an IAM role surfaced to your repo via attribute-based\n" + " authorization (ABAC)\n\n" + "You do not provide an AWS account, VPC, subnet, or state bucket.\n\n" + "To request an environment:\n" + " 1. Contact the platform team with your repo name + the\n" + " environment name you need (e.g. 'dev').\n" + " 2. The platform team provisions the account/network/state/role\n" + " and binds the environment to your repo.\n" + " 3. Your next pipeline run will proceed normally.\n\n" + "Expected turnaround: contact the platform team for current SLA.\n" + "===================================\n" + ) + + +def check(contract_path=None, env_name=None, root=None): + """Return (ok: bool, message: str). + + If env_name is None it is read from the contract at contract_path. + ok is True when an environment definition exists; False otherwise. + On False, message is the friendly onboarding prompt. + """ + if env_name is None: + if contract_path is None: + return (False, "no contract or environment name supplied") + env_name = _contract_environment(contract_path) + if env_name is None: + return (False, "contract has no 'environment' field") + + env_file = _environments_dir(root) / f"{env_name}.json" + if env_file.is_file(): + return (True, f"environment '{env_name}' is bound ({env_file})") + return (False, _onboarding_message(env_name)) + + +def main(argv): + contract_path = None + env_name = None + for arg in argv[1:]: + if arg.startswith("--env="): + env_name = arg.split("=", 1)[1] + elif arg.startswith("--"): + sys.stderr.write(f"unknown flag: {arg}\n") + return 2 + else: + contract_path = arg + + ok, message = check(contract_path=contract_path, env_name=env_name) + if ok: + print(message) + return 0 + sys.stdout.write(message) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) \ No newline at end of file diff --git a/core/environments/README.md b/core/environments/README.md new file mode 100644 index 0000000..9c184cf --- /dev/null +++ b/core/environments/README.md @@ -0,0 +1,27 @@ +# Platform-managed environments + +This directory holds environment definitions used by the onboarding scaffold. +Each file is a named environment the platform owns (an AWS account or +scoped partition, a network, a state backend, and an IAM role surfaced to +the consumer via ABAC). + +A consumer never provides an AWS account, VPC, subnet, S3 state bucket, or +runner key — the platform manages all of that here. + +## Files + +- `dev.json` — the default dev environment (autonomous, confidence ≥ 0.50). + +## How it is used + +`core/environment_check.py` reads a contract's `environment` field and +looks up the matching `.json` in this directory. If no matching file +exists, the check prints a friendly onboarding prompt and exits non-zero, +halting the pipeline before any work is done. + +## Adding an environment + +A new environment is a platform-team action: provision the AWS account / +network / state backend / IAM role, then add a `.json` here and bind +it to the consumer repo. Self-service environment provisioning is on the +roadmap; today it is a platform-team action. \ No newline at end of file diff --git a/core/environments/dev.json b/core/environments/dev.json new file mode 100644 index 0000000..947b503 --- /dev/null +++ b/core/environments/dev.json @@ -0,0 +1,17 @@ +{ + "name": "dev", + "description": "Default platform-managed dev environment for onboarding demos.", + "account_id": "000000000000", + "region": "us-east-1", + "state_backend": { + "bucket": "acdl-dev-state", + "lock_table": "acdl-dev-locks" + }, + "network": { + "vpc_cidr": "10.0.0.0/16", + "azs": ["us-east-1a", "us-east-1b"] + }, + "runner_role_arn": "arn:aws:iam::000000000000:role/acdl-dev-runner", + "autonomy": "full", + "confidence_threshold": 0.50 +} \ No newline at end of file diff --git a/scripts/run_platform.sh b/scripts/run_platform.sh index 570a7f8..19068ac 100755 --- a/scripts/run_platform.sh +++ b/scripts/run_platform.sh @@ -89,6 +89,19 @@ CONTRACT_ID="11111111-1111-1111-1111-111111111111" # spike fixed UUID WORK="/tmp/acdl_platform_run" rm -rf "$WORK"; mkdir -p "$WORK" +echo "=== Step 0: environment onboarding check ===" +if [ -f "$CONTRACT" ]; then + python3 core/environment_check.py "$CONTRACT" || { + echo "FAIL: environment not bound — see the onboarding prompt above" >&2 + exit 1 + } +else + python3 core/environment_check.py --env=dev || { + echo "FAIL: environment not bound — see the onboarding prompt above" >&2 + exit 1 + } +fi + echo "=== Step 1: validate contract against contract.schema.json ===" [ -f "$CONTRACT" ] || fail "contract file $CONTRACT missing" python3 -c " diff --git a/tests/test_environment_check.py b/tests/test_environment_check.py new file mode 100644 index 0000000..204078d --- /dev/null +++ b/tests/test_environment_check.py @@ -0,0 +1,99 @@ +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +ROOT = Path(__file__).resolve().parent.parent + +from core.environment_check import check, _onboarding_message + + +class TestEnvironmentCheck: + def test_dev_environment_is_bound(self): + ok, msg = check(env_name="dev", root=ROOT) + assert ok is True + assert "dev" in msg + + def test_missing_environment_emits_onboarding_prompt(self): + ok, msg = check(env_name="nonexistent-env", root=ROOT) + assert ok is False + assert "nonexistent-env" in msg + assert "onboarding" in msg.lower() or "Environment Onboarding" in msg + assert "platform team" in msg.lower() + + def test_onboarding_message_lists_platform_provisions(self): + msg = _onboarding_message("qa") + assert "qa" in msg + assert "AWS account" in msg + assert "network" in msg.lower() + assert "state backend" in msg.lower() + assert "IAM role" in msg + + def test_contract_with_dev_environment_passes(self): + ok, msg = check(contract_path=str(ROOT / "contracts/static-asset.yaml"), root=ROOT) + assert ok is True + assert "dev" in msg + + def test_contract_with_missing_environment_fails(self, tmp_path): + contract = tmp_path / "contract.yaml" + contract.write_text( + "uses: acdl/pipelines/deploy.yaml@v1.4\n" + "module: static-asset\n" + "environment: no-such-env\n" + "inputs:\n bucket_name: x\n region: us-east-1\n" + ) + ok, msg = check(contract_path=str(contract), root=ROOT) + assert ok is False + assert "no-such-env" in msg + + def test_no_contract_or_env_returns_false(self): + ok, msg = check(root=ROOT) + assert ok is False + + def test_contract_without_environment_field_returns_false(self, tmp_path): + contract = tmp_path / "contract.yaml" + contract.write_text( + "uses: acdl/pipelines/deploy.yaml@v1.4\n" + "module: static-asset\n" + "inputs:\n bucket_name: x\n region: us-east-1\n" + ) + ok, msg = check(contract_path=str(contract), root=ROOT) + assert ok is False + + +class TestEnvironmentDefinitions: + def test_dev_environment_file_exists(self): + assert (ROOT / "core/environments/dev.json").is_file() + + def test_dev_environment_file_is_valid_json(self): + import json + d = json.load(open(ROOT / "core/environments/dev.json")) + assert d["name"] == "dev" + assert "account_id" in d + assert "region" in d + assert "state_backend" in d + assert "network" in d + assert "runner_role_arn" in d + + def test_environments_readme_exists(self): + assert (ROOT / "core/environments/README.md").is_file() + + +class TestRunPlatformWireIn: + def test_run_platform_sh_calls_environment_check(self): + content = open(ROOT / "scripts/run_platform.sh").read() + assert "environment_check.py" in content + assert "Step 0: environment onboarding check" in content + + def test_check_only_passes_with_dev_environment(self): + import subprocess + result = subprocess.run( + ["bash", str(ROOT / "scripts/run_platform.sh"), "--check-only"], + capture_output=True, text=True, cwd=str(ROOT), + timeout=30, + ) + assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" + assert "PLATFORM CHECK OK" in result.stdout + assert "environment" in result.stdout.lower() or "Step 0" in result.stdout \ No newline at end of file