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/<name>.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).
This commit is contained in:
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Environment onboarding check.
|
||||||
|
|
||||||
|
Reads a contract's `environment` field and looks up the matching
|
||||||
|
`core/environments/<name>.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 <contract.yaml>
|
||||||
|
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))
|
||||||
@@ -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 `<name>.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 `<name>.json` here and bind
|
||||||
|
it to the consumer repo. Self-service environment provisioning is on the
|
||||||
|
roadmap; today it is a platform-team action.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -89,6 +89,19 @@ CONTRACT_ID="11111111-1111-1111-1111-111111111111" # spike fixed UUID
|
|||||||
WORK="/tmp/acdl_platform_run"
|
WORK="/tmp/acdl_platform_run"
|
||||||
rm -rf "$WORK"; mkdir -p "$WORK"
|
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 ==="
|
echo "=== Step 1: validate contract against contract.schema.json ==="
|
||||||
[ -f "$CONTRACT" ] || fail "contract file $CONTRACT missing"
|
[ -f "$CONTRACT" ] || fail "contract file $CONTRACT missing"
|
||||||
python3 -c "
|
python3 -c "
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user