Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1db5ca8286 | |||
| 93ae9e4a39 | |||
| d3179fff37 | |||
| c029b102a3 |
+14
-24
@@ -50,22 +50,13 @@ from core import env
|
||||
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.
|
||||
P7 (REQ-171): delegates to core.environment_check.load() (dedup —
|
||||
the two were verbatim duplicates). The environment_check module is
|
||||
in the same core/ package, so the import works both as a package
|
||||
import and as a script (`python3 core/contract_resolver.py`).
|
||||
"""
|
||||
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
|
||||
from core import environment_check
|
||||
return environment_check.load(env_name, root=repo_root)
|
||||
|
||||
|
||||
def _load_json(path):
|
||||
@@ -534,10 +525,14 @@ def resolve(contract_path, repo_root=None, environment_override=None):
|
||||
f"module '{module_name}' version '{version}' not found in registry")
|
||||
module_inputs = module_entry.get("inputs", {})
|
||||
|
||||
# Determine if L1 or L2
|
||||
# Determine if L1 or L2 — prefer the registry `kind` field (P7,
|
||||
# REQ-171); fall back to the path heuristic for entries that
|
||||
# predate the kind field.
|
||||
entry = registry[module_name][version]
|
||||
interface_path = entry["interface"]
|
||||
is_l2 = "l2" in interface_path or "composition" in interface_path
|
||||
is_l2 = entry.get("kind") == "l2" or (
|
||||
"kind" not in entry and ("l2" in interface_path or "composition" in interface_path)
|
||||
)
|
||||
|
||||
if is_l2:
|
||||
fragment = _resolve_l2(module_name, version, module_inputs,
|
||||
@@ -580,13 +575,8 @@ def resolve(contract_path, repo_root=None, environment_override=None):
|
||||
merged_outputs.update(fragment.get("outputs", {}))
|
||||
all_resources.extend(fragment["resources"])
|
||||
|
||||
# Determine stack kind: L2 if any module is L2 or if multi-module
|
||||
if multi_module:
|
||||
kind = "l2"
|
||||
elif any_l2:
|
||||
kind = "l2"
|
||||
else:
|
||||
kind = "l1"
|
||||
# Determine stack kind: L2 if any module is L2 or if multi-module (P7)
|
||||
kind = "l2" if (multi_module or any_l2) else "l1"
|
||||
|
||||
stack_instance = {
|
||||
"version": "1.0.0",
|
||||
|
||||
+54
-72
@@ -146,14 +146,18 @@ def _check_environment_schema_validation() -> Tuple[Status, str]:
|
||||
])
|
||||
|
||||
|
||||
def _check_resolver_static_assets() -> Tuple[Status, str]:
|
||||
"""CAP-003: contract_resolver resolves static-assets to a Target Stack."""
|
||||
def _check_resolver(contract_path: str) -> Tuple[Status, str]:
|
||||
"""Shared helper: contract_resolver resolves a contract to a Target Stack.
|
||||
|
||||
Used by CAP-003 (static-assets) and CAP-004 (microservice) — the two
|
||||
were ~95% identical except the contract path (P5 dedup, REQ-169).
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as t:
|
||||
out = t.name
|
||||
try:
|
||||
return _check_subprocess([
|
||||
"python3", "core/contract_resolver.py",
|
||||
"contracts/static-assets.yml", out,
|
||||
contract_path, out,
|
||||
])
|
||||
finally:
|
||||
try:
|
||||
@@ -162,20 +166,14 @@ def _check_resolver_static_assets() -> Tuple[Status, str]:
|
||||
pass
|
||||
|
||||
|
||||
def _check_resolver_static_assets() -> Tuple[Status, str]:
|
||||
"""CAP-003: contract_resolver resolves static-assets to a Target Stack."""
|
||||
return _check_resolver("contracts/static-assets.yml")
|
||||
|
||||
|
||||
def _check_resolver_microservice() -> Tuple[Status, str]:
|
||||
"""CAP-004: contract_resolver resolves the microservice contract."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as t:
|
||||
out = t.name
|
||||
try:
|
||||
return _check_subprocess([
|
||||
"python3", "core/contract_resolver.py",
|
||||
"contracts/microservice.yml", out,
|
||||
])
|
||||
finally:
|
||||
try:
|
||||
os.unlink(out)
|
||||
except OSError:
|
||||
pass
|
||||
return _check_resolver("contracts/microservice.yml")
|
||||
|
||||
|
||||
def _check_adapter_emits_terraform() -> Tuple[Status, str]:
|
||||
@@ -325,21 +323,24 @@ def _load_aws_env() -> Dict[str, str]:
|
||||
return env
|
||||
|
||||
|
||||
def _check_live_terraform_plan_microservice() -> Tuple[Status, str]:
|
||||
"""CAP-013: terraform init+validate+plan against live AWS for the
|
||||
microservice stack (D-093 live-AWS tier of the headline E2E).
|
||||
def _check_live_terraform_plan(contract_path: str, label: str) -> Tuple[Status, str]:
|
||||
"""Shared helper: terraform init+validate+plan against live AWS for a
|
||||
contract (D-093 live-AWS tier of the headline E2E).
|
||||
|
||||
Requires AWS credentials (NOVA_AWS_ACCESS_KEY_ID etc. in .env.secrets;
|
||||
NOVA_* only — the ACDL_* fallback was removed in v1.15 P5, REQ-164).
|
||||
Runs in a temp dir; does NOT apply (plan only)."""
|
||||
Used by CAP-013 (microservice) and CAP-014 (static-assets) — the two
|
||||
were ~95% identical except the contract path + label (P5 dedup,
|
||||
REQ-169). Requires AWS credentials (NOVA_AWS_ACCESS_KEY_ID etc. in
|
||||
.env.secrets; NOVA_* only — the ACDL_* fallback was removed in v1.15
|
||||
P5, REQ-164). Runs in a temp dir; does NOT apply (plan only).
|
||||
"""
|
||||
import tempfile, os
|
||||
work = tempfile.mkdtemp(prefix="nova_regr_live_")
|
||||
work = tempfile.mkdtemp(prefix=f"nova_regr_live_{label}_")
|
||||
stack_path = os.path.join(work, "stack.json")
|
||||
tf_dir = os.path.join(work, "tf")
|
||||
os.makedirs(tf_dir, exist_ok=True)
|
||||
rc, out, err = _run_subprocess([
|
||||
"python3", "core/contract_resolver.py",
|
||||
"contracts/microservice.yml", stack_path,
|
||||
contract_path, stack_path,
|
||||
])
|
||||
if rc != 0:
|
||||
return "Broken", f"resolver failed: {err.strip()[-200:]}"
|
||||
@@ -366,47 +367,19 @@ def _check_live_terraform_plan_microservice() -> Tuple[Status, str]:
|
||||
)
|
||||
if rc != 0:
|
||||
return "Decayed", f"terraform plan failed: {err.strip()[-200:]}"
|
||||
return "Verified", "terraform init+validate+plan OK (live AWS, microservice)"
|
||||
return "Verified", f"terraform init+validate+plan OK (live AWS, {label})"
|
||||
|
||||
|
||||
def _check_live_terraform_plan_microservice() -> Tuple[Status, str]:
|
||||
"""CAP-013: terraform init+validate+plan against live AWS for the
|
||||
microservice stack (D-093 live-AWS tier of the headline E2E)."""
|
||||
return _check_live_terraform_plan("contracts/microservice.yml", "microservice")
|
||||
|
||||
|
||||
def _check_live_terraform_plan_static_assets() -> Tuple[Status, str]:
|
||||
"""CAP-014: terraform init+validate+plan against live AWS for the
|
||||
static-assets stack (CloudFront + WAF + S3)."""
|
||||
import tempfile, os
|
||||
work = tempfile.mkdtemp(prefix="nova_regr_live_sa_")
|
||||
stack_path = os.path.join(work, "stack.json")
|
||||
tf_dir = os.path.join(work, "tf")
|
||||
os.makedirs(tf_dir, exist_ok=True)
|
||||
rc, out, err = _run_subprocess([
|
||||
"python3", "core/contract_resolver.py",
|
||||
"contracts/static-assets.yml", stack_path,
|
||||
])
|
||||
if rc != 0:
|
||||
return "Broken", f"resolver failed: {err.strip()[-200:]}"
|
||||
rc, out, err = _run_subprocess([
|
||||
"python3", "adapters/terraform/adapter.py", stack_path, tf_dir,
|
||||
])
|
||||
if rc != 0:
|
||||
return "Broken", f"adapter failed: {err.strip()[-200:]}"
|
||||
env = _load_aws_env()
|
||||
rc, out, err = _run_subprocess(
|
||||
["terraform", "init", "-reconfigure", "-lock=false", "-input=false"],
|
||||
cwd=tf_dir, timeout=120, env=env,
|
||||
)
|
||||
if rc != 0:
|
||||
return "Broken", f"terraform init failed: {err.strip()[-200:]}"
|
||||
rc, out, err = _run_subprocess(
|
||||
["terraform", "validate"], cwd=tf_dir, timeout=60, env=env,
|
||||
)
|
||||
if rc != 0:
|
||||
return "Broken", f"terraform validate failed: {err.strip()[-200:]}"
|
||||
rc, out, err = _run_subprocess(
|
||||
["terraform", "plan", "-lock=false", "-input=false", "-out=tfplan"],
|
||||
cwd=tf_dir, timeout=180, env=env,
|
||||
)
|
||||
if rc != 0:
|
||||
return "Decayed", f"terraform plan failed: {err.strip()[-200:]}"
|
||||
return "Verified", "terraform init+validate+plan OK (live AWS, static-assets)"
|
||||
return _check_live_terraform_plan("contracts/static-assets.yml", "static-assets")
|
||||
|
||||
|
||||
def _check_dynamodb_outbox_table() -> Tuple[Status, str]:
|
||||
@@ -474,16 +447,30 @@ def _check_lifecycle_module_terraform(module: str) -> Tuple[Status, str]:
|
||||
["terraform", "fmt", "-check", "-diff", str(tf_dir)], timeout=30)
|
||||
if rc != 0:
|
||||
return "Broken", f"terraform fmt -check failed: {err.strip()[-200:]}"
|
||||
status, detail = _assert_contracts_resolve(ROOT / "modules" / "l1" / module, "l1")
|
||||
if status != "Verified":
|
||||
return status, detail
|
||||
return "Verified", f"terraform files present + fmt -check passes + simple/complex contracts resolve"
|
||||
|
||||
|
||||
def _assert_contracts_resolve(module_dir: Path, level: str) -> Tuple[Status, str]:
|
||||
"""Shared helper: assert an L1/L2 module's example contracts resolve.
|
||||
|
||||
Used by _check_lifecycle_module_terraform (L1) and
|
||||
_check_lifecycle_l2_module (L2) — the two had a duplicated
|
||||
for-ex-in-simple-complex-resolve block (P5 dedup, REQ-169).
|
||||
``level`` is "l1" or "l2" (selects the examples dir parent).
|
||||
"""
|
||||
for ex in ["simple", "complex"]:
|
||||
contract = ROOT / "modules" / "l1" / module / "examples" / f"{ex}.yml"
|
||||
contract = module_dir / "examples" / f"{ex}.yml"
|
||||
if not contract.is_file():
|
||||
return "Broken", f"modules/l1/{module}/examples/{ex}.yml missing"
|
||||
return "Broken", f"{module_dir.relative_to(ROOT)}/examples/{ex}.yml missing"
|
||||
rc, out, err = _run_subprocess([
|
||||
"python3", "core/contract_resolver.py", str(contract), "/dev/null",
|
||||
], timeout=30)
|
||||
if rc != 0:
|
||||
return "Broken", f"{ex}.yml resolver failed: {err.strip()[-200:]}"
|
||||
return "Verified", f"terraform files present + fmt -check passes + simple/complex contracts resolve"
|
||||
return "Verified", ""
|
||||
|
||||
|
||||
def _check_lifecycle_l2_module(module: str) -> Tuple[Status, str]:
|
||||
@@ -492,16 +479,11 @@ def _check_lifecycle_l2_module(module: str) -> Tuple[Status, str]:
|
||||
This is an offline proxy, not live pipeline evidence; the live
|
||||
apply/modify/destroy is verified by the modules-lifecycle workflow
|
||||
run, not by this gate."""
|
||||
for ex in ["simple", "complex"]:
|
||||
contract = ROOT / "modules" / "l2" / module / "examples" / f"{ex}.yml"
|
||||
if not contract.is_file():
|
||||
return "Broken", f"modules/l2/{module}/examples/{ex}.yml missing"
|
||||
rc, out, err = _run_subprocess([
|
||||
"python3", "core/contract_resolver.py", str(contract), "/dev/null",
|
||||
], timeout=30)
|
||||
if rc != 0:
|
||||
return "Broken", f"{ex}.yml resolver failed: {err.strip()[-200:]}"
|
||||
return "Verified", f"L2 composition resolves (simple + complex contracts; offline proxy)"
|
||||
module_dir = ROOT / "modules" / "l2" / module
|
||||
status, detail = _assert_contracts_resolve(module_dir, "l2")
|
||||
if status != "Verified":
|
||||
return status, detail
|
||||
return "Verified", "L2 composition resolves (simple + complex contracts; offline proxy)"
|
||||
|
||||
|
||||
def _check_cap_017_dynamodb() -> Tuple[Status, str]:
|
||||
|
||||
+28
-14
@@ -4,7 +4,8 @@
|
||||
"interface": "modules/l1/s3/interface.json",
|
||||
"terraform_dir": "modules/l1/s3/terraform",
|
||||
"published_at": "2026-07-21T19:00:00Z",
|
||||
"deprecated": false
|
||||
"deprecated": false,
|
||||
"kind": "l1"
|
||||
}
|
||||
},
|
||||
"vpc": {
|
||||
@@ -12,7 +13,8 @@
|
||||
"interface": "modules/l1/vpc/interface.json",
|
||||
"published_at": "2026-07-21T21:30:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/vpc/terraform"
|
||||
"terraform_dir": "modules/l1/vpc/terraform",
|
||||
"kind": "l1"
|
||||
}
|
||||
},
|
||||
"ecs-cluster": {
|
||||
@@ -20,7 +22,8 @@
|
||||
"interface": "modules/l1/ecs-cluster/interface.json",
|
||||
"published_at": "2026-07-21T21:30:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/ecs-cluster/terraform"
|
||||
"terraform_dir": "modules/l1/ecs-cluster/terraform",
|
||||
"kind": "l1"
|
||||
}
|
||||
},
|
||||
"ecs-service": {
|
||||
@@ -28,7 +31,8 @@
|
||||
"interface": "modules/l1/ecs-service/interface.json",
|
||||
"published_at": "2026-07-21T21:30:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/ecs-service/terraform"
|
||||
"terraform_dir": "modules/l1/ecs-service/terraform",
|
||||
"kind": "l1"
|
||||
}
|
||||
},
|
||||
"iam-role": {
|
||||
@@ -36,7 +40,8 @@
|
||||
"interface": "modules/l1/iam-role/interface.json",
|
||||
"published_at": "2026-07-21T21:30:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/iam-role/terraform"
|
||||
"terraform_dir": "modules/l1/iam-role/terraform",
|
||||
"kind": "l1"
|
||||
}
|
||||
},
|
||||
"alb": {
|
||||
@@ -44,7 +49,8 @@
|
||||
"interface": "modules/l1/alb/interface.json",
|
||||
"published_at": "2026-07-21T21:30:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/alb/terraform"
|
||||
"terraform_dir": "modules/l1/alb/terraform",
|
||||
"kind": "l1"
|
||||
}
|
||||
},
|
||||
"ecr": {
|
||||
@@ -52,7 +58,8 @@
|
||||
"interface": "modules/l1/ecr/interface.json",
|
||||
"published_at": "2026-07-21T21:30:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/ecr/terraform"
|
||||
"terraform_dir": "modules/l1/ecr/terraform",
|
||||
"kind": "l1"
|
||||
}
|
||||
},
|
||||
"cloudfront": {
|
||||
@@ -60,7 +67,8 @@
|
||||
"interface": "modules/l1/cloudfront/interface.json",
|
||||
"published_at": "2026-07-22T19:00:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/cloudfront/terraform"
|
||||
"terraform_dir": "modules/l1/cloudfront/terraform",
|
||||
"kind": "l1"
|
||||
}
|
||||
},
|
||||
"waf": {
|
||||
@@ -68,7 +76,8 @@
|
||||
"interface": "modules/l1/waf/interface.json",
|
||||
"published_at": "2026-07-22T19:00:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/waf/terraform"
|
||||
"terraform_dir": "modules/l1/waf/terraform",
|
||||
"kind": "l1"
|
||||
}
|
||||
},
|
||||
"rds": {
|
||||
@@ -76,7 +85,8 @@
|
||||
"interface": "modules/l1/rds/interface.json",
|
||||
"published_at": "2026-07-22T20:00:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/rds/terraform"
|
||||
"terraform_dir": "modules/l1/rds/terraform",
|
||||
"kind": "l1"
|
||||
}
|
||||
},
|
||||
"kms-key": {
|
||||
@@ -84,7 +94,8 @@
|
||||
"interface": "modules/l1/kms-key/interface.json",
|
||||
"published_at": "2026-07-22T20:00",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/kms-key/terraform"
|
||||
"terraform_dir": "modules/l1/kms-key/terraform",
|
||||
"kind": "l1"
|
||||
}
|
||||
},
|
||||
"uptime": {
|
||||
@@ -92,21 +103,24 @@
|
||||
"interface": "modules/l1/uptime/interface.json",
|
||||
"published_at": "2026-07-22T21:00",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/uptime/terraform"
|
||||
"terraform_dir": "modules/l1/uptime/terraform",
|
||||
"kind": "l1"
|
||||
}
|
||||
},
|
||||
"static-assets": {
|
||||
"1.0.0": {
|
||||
"interface": "modules/l2/static-assets/composition.json",
|
||||
"published_at": "2026-07-22T15:00:00Z",
|
||||
"deprecated": false
|
||||
"deprecated": false,
|
||||
"kind": "l2"
|
||||
}
|
||||
},
|
||||
"microservice": {
|
||||
"1.0.0": {
|
||||
"interface": "modules/l2/microservice/composition.json",
|
||||
"published_at": "2026-07-22T15:00:00Z",
|
||||
"deprecated": false
|
||||
"deprecated": false,
|
||||
"kind": "l2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-52
@@ -113,6 +113,38 @@ fi
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
# run_hitl_gate <contract_id> <resolved_env> <context>
|
||||
# REQ-108: for qa/prod/dr, call hitl_gates.attest before apply. Dev skips.
|
||||
# Extracted from the two duplicated inline blocks (P6, REQ-170).
|
||||
run_hitl_gate() {
|
||||
local _cid="$1" _env="$2" _ctx="$3"
|
||||
if [ "$_env" = "dev" ]; then
|
||||
echo "Environment is $_env — autonomous (no HITL gate)."
|
||||
return 0
|
||||
fi
|
||||
echo "Environment is $_env — HITL attestation gate required$_ctx."
|
||||
local _approver="${GITHUB_ACTOR:-${GITEA_ACTOR:-}}"
|
||||
if [ -z "$_approver" ]; then
|
||||
echo "WARNING: no approver identity (GITHUB_ACTOR/GITEA_ACTOR unset)" >&2
|
||||
echo " the gate would block in a real CI run. Passing for local." >&2
|
||||
fi
|
||||
python3 -c "
|
||||
import os, sys
|
||||
sys.path.insert(0, '.')
|
||||
from core.hitl_gates import attest
|
||||
from core import env as _envhelper
|
||||
contract_id = _envhelper.get_env('HITL_CONTRACT_ID') or os.environ['NOVA_HITL_CONTRACT_ID']
|
||||
env = _envhelper.get_env('HITL_ENV') or os.environ['NOVA_HITL_ENV']
|
||||
approver = _envhelper.get_env('HITL_APPROVER', '') or 'local-test'
|
||||
ok, reason = attest(contract_id, env, approver)
|
||||
if ok:
|
||||
print(f'HITL PASS: {reason}')
|
||||
else:
|
||||
print(f'HITL BLOCK: {reason}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" NOVA_HITL_CONTRACT_ID="$_cid" NOVA_HITL_ENV="$_env" NOVA_HITL_APPROVER="$_approver"
|
||||
}
|
||||
|
||||
# --local: run the headline E2E against the local emulating tier (D-092).
|
||||
# No AWS credentials, no Checkov, no DynamoDB. Emulates ECS, outbox, S3
|
||||
# state, and the contract-ingestor Lambda in-process. Exits 0 on success.
|
||||
@@ -142,8 +174,8 @@ stream() {
|
||||
fi
|
||||
}
|
||||
|
||||
CONTRACT_ID="11111111-1111-1111-1111-111111111111" # spike fixed UUID
|
||||
WORK="/tmp/acdl_platform_run_v18"
|
||||
CONTRACT_ID="${NOVA_CONTRACT_ID:-11111111-1111-1111-1111-111111111111}" # spike UUID (override via NOVA_CONTRACT_ID)
|
||||
WORK="${NOVA_WORK_DIR:-/tmp/nova_platform_run}"
|
||||
TF_DIR="$WORK/tf"
|
||||
rm -rf "$WORK"; mkdir -p "$TF_DIR"
|
||||
|
||||
@@ -325,31 +357,7 @@ if [ "$APPLY_ONLY" = "1" ]; then
|
||||
if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
|
||||
RESOLVED_ENV="$ENVIRONMENT_OVERRIDE"
|
||||
fi
|
||||
if [ "$RESOLVED_ENV" != "dev" ]; then
|
||||
echo "Environment is $RESOLVED_ENV — HITL attestation gate required before apply."
|
||||
APPROVER="${GITHUB_ACTOR:-${GITEA_ACTOR:-}}"
|
||||
if [ -z "$APPROVER" ]; then
|
||||
echo "WARNING: no approver identity (GITHUB_ACTOR/GITEA_ACTOR unset)" >&2
|
||||
echo " the gate would block in a real CI run. Passing for local." >&2
|
||||
fi
|
||||
python3 -c "
|
||||
import os, sys
|
||||
sys.path.insert(0, '.')
|
||||
from core.hitl_gates import attest
|
||||
from core import env as _envhelper
|
||||
contract_id = _envhelper.get_env('HITL_CONTRACT_ID') or os.environ['NOVA_HITL_CONTRACT_ID']
|
||||
env = _envhelper.get_env('HITL_ENV') or os.environ['NOVA_HITL_ENV']
|
||||
approver = _envhelper.get_env('HITL_APPROVER', '') or 'local-test'
|
||||
ok, reason = attest(contract_id, env, approver)
|
||||
if ok:
|
||||
print(f'HITL PASS: {reason}')
|
||||
else:
|
||||
print(f'HITL BLOCK: {reason}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" NOVA_HITL_CONTRACT_ID="$CONTRACT_ID" NOVA_HITL_ENV="$RESOLVED_ENV" NOVA_HITL_APPROVER="$APPROVER" || { echo "FAIL: HITL attestation gate blocked the apply" >&2; exit 1; }
|
||||
else
|
||||
echo "Environment is dev — autonomous (no HITL gate)."
|
||||
fi
|
||||
run_hitl_gate "$CONTRACT_ID" "$RESOLVED_ENV" " before apply" || { echo "FAIL: HITL attestation gate blocked the apply" >&2; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "=== Step 5: terraform apply -auto-approve ==="
|
||||
@@ -441,31 +449,7 @@ RESOLVED_ENV=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONTRACT')).
|
||||
if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
|
||||
RESOLVED_ENV="$ENVIRONMENT_OVERRIDE"
|
||||
fi
|
||||
if [ "$RESOLVED_ENV" != "dev" ]; then
|
||||
echo "Environment is $RESOLVED_ENV — HITL attestation gate required."
|
||||
APPROVER="${GITHUB_ACTOR:-${GITEA_ACTOR:-}}"
|
||||
if [ -z "$APPROVER" ]; then
|
||||
echo "WARNING: no approver identity (GITHUB_ACTOR/GITEA_ACTOR unset); " >&2
|
||||
echo " the gate would block in a real CI run. Passing for local." >&2
|
||||
fi
|
||||
python3 -c "
|
||||
import os, sys
|
||||
sys.path.insert(0, '.')
|
||||
from core.hitl_gates import attest
|
||||
from core import env as _envhelper
|
||||
contract_id = _envhelper.get_env('HITL_CONTRACT_ID') or os.environ['NOVA_HITL_CONTRACT_ID']
|
||||
env = _envhelper.get_env('HITL_ENV') or os.environ['NOVA_HITL_ENV']
|
||||
approver = _envhelper.get_env('HITL_APPROVER', '') or 'local-test'
|
||||
ok, reason = attest(contract_id, env, approver)
|
||||
if ok:
|
||||
print(f'HITL PASS: {reason}')
|
||||
else:
|
||||
print(f'HITL BLOCK: {reason}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" NOVA_HITL_CONTRACT_ID="$CONTRACT_ID" NOVA_HITL_ENV="$RESOLVED_ENV" NOVA_HITL_APPROVER="$APPROVER" || { echo "FAIL: HITL attestation gate blocked the promotion" >&2; exit 1; }
|
||||
else
|
||||
echo "Environment is dev — autonomous (no HITL gate)."
|
||||
fi
|
||||
run_hitl_gate "$CONTRACT_ID" "$RESOLVED_ENV" "" || { echo "FAIL: HITL attestation gate blocked the promotion" >&2; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "=== Step 8: write evidence event to DynamoDB outbox ==="
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync byte-identical workflows from workflows-src/ to .gitea/ + .github/ (P8, REQ-172).
|
||||
|
||||
Three workflow pairs are byte-identical Gitea + GitHub mirrors:
|
||||
ci.yml, deploy.yml, modules-lifecycle.yml.
|
||||
|
||||
This generator reads the single source from ``workflows-src/<name>`` and
|
||||
writes byte-identical copies to both ``.gitea/workflows/<name>`` and
|
||||
``.github/workflows/<name>``. Use ``--check`` to verify the committed
|
||||
files match the generated output (CI gate); use ``--write`` to regenerate
|
||||
the committed files from the sources.
|
||||
|
||||
The 4 GitHub-only workflows (platform-test.yml, primitives-plan.yml,
|
||||
patterns-plan.yml, release.yml) have no Gitea mirror (act_runner feature
|
||||
gaps) and are NOT touched by this generator.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import filecmp
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC_DIR = ROOT / "workflows-src"
|
||||
GITEA_DIR = ROOT / ".gitea" / "workflows"
|
||||
GITHUB_DIR = ROOT / ".github" / "workflows"
|
||||
|
||||
PAIRS = ["ci.yml", "deploy.yml", "modules-lifecycle.yml"]
|
||||
|
||||
|
||||
def _read_source(name: str) -> str:
|
||||
src = SRC_DIR / name
|
||||
if not src.is_file():
|
||||
raise FileNotFoundError(f"source {src} missing")
|
||||
return src.read_text()
|
||||
|
||||
|
||||
def check() -> int:
|
||||
"""Verify committed files match the sources. Exit 0 if clean, 1 if drift."""
|
||||
drift = []
|
||||
for name in PAIRS:
|
||||
content = _read_source(name)
|
||||
for dest_dir in (GITEA_DIR, GITHUB_DIR):
|
||||
dest = dest_dir / name
|
||||
if not dest.is_file():
|
||||
drift.append(f"{dest} MISSING (expected from workflows-src/{name})")
|
||||
continue
|
||||
if dest.read_text() != content:
|
||||
drift.append(f"{dest} DRIFTED from workflows-src/{name}")
|
||||
if drift:
|
||||
for d in drift:
|
||||
print(f"DRIFT: {d}", file=sys.stderr)
|
||||
print("\nRun: python3 scripts/sync_workflows.py --write", file=sys.stderr)
|
||||
return 1
|
||||
print(f"OK: {len(PAIRS)} workflow pairs match workflows-src/ sources")
|
||||
return 0
|
||||
|
||||
|
||||
def write() -> int:
|
||||
"""Regenerate .gitea/ + .github/ from workflows-src/ sources."""
|
||||
for name in PAIRS:
|
||||
content = _read_source(name)
|
||||
for dest_dir in (GITEA_DIR, GITHUB_DIR):
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
(dest_dir / name).write_text(content)
|
||||
print(f"wrote: .gitea/workflows/{name} + .github/workflows/{name}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Sync byte-identical workflow pairs.")
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--check", action="store_true", help="verify committed files match sources (CI gate)")
|
||||
group.add_argument("--write", action="store_true", help="regenerate committed files from sources")
|
||||
args = parser.parse_args(argv)
|
||||
if args.check:
|
||||
return check()
|
||||
return write()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -101,10 +101,25 @@ class TestWorkflowConformance:
|
||||
assert (ROOT / ".github/workflows/ci.yml").is_file()
|
||||
|
||||
def test_workflows_are_byte_identical(self):
|
||||
# P8 (REQ-172): the byte-identity is now enforced by
|
||||
# scripts/sync_workflows.py --check (generated from workflows-src/).
|
||||
# The two dirs must still be byte-identical (the generator writes
|
||||
# the same source to both); this assertion is the belt, the
|
||||
# generator --check is the suspenders.
|
||||
gitea = open(ROOT / ".gitea/workflows/ci.yml", "rb").read()
|
||||
github = open(ROOT / ".github/workflows/ci.yml", "rb").read()
|
||||
assert gitea == github, "Gitea and GitHub workflows must be byte-identical"
|
||||
|
||||
def test_sync_workflows_check_passes(self):
|
||||
"""P8 (REQ-172): sync_workflows.py --check exits 0 (committed
|
||||
files match the workflows-src/ sources)."""
|
||||
import subprocess
|
||||
rc = subprocess.call(
|
||||
[sys.executable, "scripts/sync_workflows.py", "--check"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
assert rc == 0, "sync_workflows.py --check failed — run scripts/sync_workflows.py --write"
|
||||
|
||||
def test_gitea_workflow_name_matches_contract(self):
|
||||
wf = _load_workflow(".gitea/workflows/ci.yml")
|
||||
contract = _load_yaml("pipelines/ci.yml")
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# ACDL CI Pipeline — Gitea Actions (dev environment)
|
||||
#
|
||||
# This workflow implements the central pipeline contract:
|
||||
# pipelines/ci.yml (validated against schemas/pipeline.schema.json)
|
||||
#
|
||||
# The same contract is implemented by .github/workflows/ci.yml (GitHub
|
||||
# Actions, production). Both files must be byte-identical — the only
|
||||
# declared difference is the forge/runtime, not the stages or commands.
|
||||
#
|
||||
# Shell reproducibility: scripts/run_ci.sh runs the same 3 stages locally.
|
||||
#
|
||||
# Stages (from the contract):
|
||||
# 1. lint — py_compile all Python files
|
||||
# 2. test — pytest test suite (offline, no AWS)
|
||||
# 3. check-only — run_platform.sh --check-only (offline, no AWS)
|
||||
name: acdl-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Compile all Python files
|
||||
run: |
|
||||
python3 -m py_compile \
|
||||
core/confidence_signal.py \
|
||||
core/outbox_writer.py \
|
||||
core/output_publisher.py \
|
||||
core/contract_resolver.py \
|
||||
core/lambda/contract_ingestor.py \
|
||||
adapters/terraform/adapter.py \
|
||||
adapters/terraform/policy/checkov_adapter.py \
|
||||
scripts/push_consumer_image.py
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
|
||||
- name: Install test dependencies
|
||||
run: pip install -r requirements-test.txt
|
||||
|
||||
- name: Run pytest
|
||||
run: python3 -m pytest tests/ -v --tb=short
|
||||
|
||||
check-only:
|
||||
name: Platform check-only (offline)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
|
||||
- name: Install runtime dependencies
|
||||
run: pip install jsonschema pyyaml boto3
|
||||
|
||||
- name: Run platform check-only
|
||||
run: bash scripts/run_platform.sh --check-only
|
||||
@@ -0,0 +1,166 @@
|
||||
# ACDL Reusable Deploy Workflow — Gitea Actions (dev environment)
|
||||
#
|
||||
# This reusable workflow implements the central deployment pipeline contract:
|
||||
# pipelines/contract.yml (validated against schemas/deploy-pipeline.schema.json)
|
||||
#
|
||||
# The same contract is implemented by .github/workflows/deploy.yml (GitHub
|
||||
# Actions, production). Both files must be byte-identical — the only
|
||||
# declared difference is the forge/runtime, not the stages or commands.
|
||||
#
|
||||
# Consumer repos invoke this workflow via a versioned tag (floating MAJOR + MINOR):
|
||||
# uses: acdl/.gitea/workflows/deploy.yml@v1.9 (Gitea)
|
||||
# uses: acdl/.github/workflows/deploy.yml@v1.9 (GitHub)
|
||||
#
|
||||
# Unversioned references (@main, bare) are discouraged — the consumer's setup
|
||||
# must be immutable + resilient. The versioned tag is the only immutability
|
||||
# lever (version constraints cannot be expressed inside the contract).
|
||||
#
|
||||
# What this workflow does:
|
||||
# 1. Checks out the consumer repo (the repo that invoked the workflow).
|
||||
# 2. Checks out the ACDL platform repo into the workspace (platform/).
|
||||
# This is the run-time fetch — consumers never clone the platform repo.
|
||||
# 3. Installs runtime deps: Python 3.12, Terraform 1.9.*, Checkov.
|
||||
# 4. Configures AWS auth (OIDC default; static-key override via secrets).
|
||||
# 5. Runs scripts/run_platform.sh against the consumer's contract path.
|
||||
# 6. Uploads artifacts (emitted Terraform, Checkov JSON, confidence JSON,
|
||||
# platform log) for auditability.
|
||||
#
|
||||
# Inputs:
|
||||
# contract — path to the consumer's contract YAML (default .nova/contract.yml)
|
||||
# mode — full | plan-only | check-only (default full; dev = full apply,
|
||||
# higher environments hold for HITL — the calling repo or the
|
||||
# forge environment gate enforces that)
|
||||
#
|
||||
# Auth (zero-trust default — see README.md#credentials--zero-trust):
|
||||
# OIDC federation is the default. permissions: id-token: write lets the
|
||||
# forge mint a short-lived STS token. The role-to-assume is scoped by the
|
||||
# consumer's repository identity (ABAC) — the workflow assumes the role
|
||||
# that matches repo:org/consumer-repo:ref:refs/heads/main, and the session
|
||||
# policy restricts view/update to resources tagged acdl:owner=<consumer-repo>.
|
||||
#
|
||||
# Override (where OIDC is unavailable, e.g. Gitea pending
|
||||
# go-gitea/gitea#36988): set NOVA_AWS_ACCESS_KEY_ID + NOVA_AWS_SECRET_ACCESS_KEY
|
||||
# as repository secrets. The platform-managed scheduled pipeline rotates
|
||||
# the key on a daily cadence. When .env.secrets is used locally instead,
|
||||
# rotating the key out of band is the consumer's responsibility.
|
||||
name: nova-deploy
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
contract:
|
||||
description: Path to the consumer contract YAML (in the consumer repo)
|
||||
type: string
|
||||
default: .nova/contract.yml
|
||||
mode:
|
||||
description: Pipeline mode — full (apply), plan-only, check-only, or decommission
|
||||
type: string
|
||||
default: full
|
||||
changeRequestId:
|
||||
description: Change request ID (required for decommission mode — validated against CMDB)
|
||||
type: string
|
||||
default: ""
|
||||
environment:
|
||||
description: Target environment override (dev/qa/prod/dr); when empty, the contract's environment field is used
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out consumer repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check out ACDL platform repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: acdl/acdl
|
||||
path: platform
|
||||
ref: v1.9
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install runtime dependencies
|
||||
run: |
|
||||
pip install --break-system-packages jsonschema pyyaml boto3
|
||||
pip install --break-system-packages "checkov>=3.2,<4"
|
||||
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
|
||||
- name: Configure AWS credentials (OIDC default + static-key override)
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
# P4 (REQ-163): IAM role renamed acdl-deploy- → nova-deploy-.
|
||||
role-to-assume: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/nova-deploy-{1}', secrets.NOVA_AWS_ACCOUNT_ID, github.repository_id) || '' }}
|
||||
aws-region: us-east-1
|
||||
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
- name: Run the platform pipeline
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
MODE_FLAG=""
|
||||
case "${{ inputs.mode }}" in
|
||||
full) MODE_FLAG="" ;;
|
||||
plan-only) MODE_FLAG="--plan-only" ;;
|
||||
check-only) MODE_FLAG="--check-only" ;;
|
||||
decommission)
|
||||
if [ -z "${{ inputs.changeRequestId }}" ]; then
|
||||
echo "FAIL: changeRequestId is required for decommission mode"
|
||||
exit 1
|
||||
fi
|
||||
MODE_FLAG="--decommission ${{ inputs.changeRequestId }}"
|
||||
;;
|
||||
*) echo "Unknown mode: ${{ inputs.mode }}"; exit 1 ;;
|
||||
esac
|
||||
ENV_FLAG=""
|
||||
if [ -n "${{ inputs.environment }}" ]; then
|
||||
ENV_FLAG="--environment ${{ inputs.environment }}"
|
||||
fi
|
||||
bash platform/scripts/run_platform.sh $MODE_FLAG $ENV_FLAG "${{ inputs.contract }}"
|
||||
|
||||
- name: Post stage summary comment to PR
|
||||
if: success() && github.event_name == 'pull_request'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
run: |
|
||||
bash platform/scripts/post_stage_comment.sh deploy pass '{"mode":"${{ inputs.mode }}","runId":"${{ github.run_id }}"}'
|
||||
|
||||
- name: Report error to platform team (on failure)
|
||||
if: failure()
|
||||
env:
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
aws lambda invoke-function-url \
|
||||
--function-url "${{ secrets.NOVA_LAMBDA_URL }}" \
|
||||
--cli-binary-format raw-in-base64-out \
|
||||
--payload "$(python3 -c "import json,os; print(json.dumps({'action':'report_error','consumerRepo':os.environ.get('GITHUB_REPOSITORY',''),'contractId':'${{ github.run_id }}','error':'Deploy pipeline failed. See run logs.','runUrl':'${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}','environment':'dev'}))")" \
|
||||
/dev/null || true
|
||||
|
||||
- name: Upload emitted Terraform
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nova-terraform
|
||||
path: /tmp/acdl_platform_run_v18/tf/*.tf
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload platform log
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nova-platform-log
|
||||
path: platform/logs/
|
||||
if-no-files-found: warn
|
||||
@@ -0,0 +1,207 @@
|
||||
# ACDL Modules Lifecycle Pipeline — Gitea Actions (dev environment)
|
||||
#
|
||||
# Matrix-runs each L1 module's examples/{simple,complex}.yml contracts through
|
||||
# apply→modify→destroy against live AWS. No per-module Python. The "test" =
|
||||
# the pipeline cell going green.
|
||||
#
|
||||
# Also matrix-runs L2 composition modules (static-assets, microservice) through
|
||||
# the same apply→modify→destroy lifecycle. L2 = composition only (no L2
|
||||
# terraform files); the composition must be deterministic.
|
||||
#
|
||||
# This workflow implements pipelines/modules-lifecycle.yml (byte-identical
|
||||
# in .gitea/workflows/ and .github/workflows/).
|
||||
#
|
||||
# Lifecycle mode (REQ-134, v1.12): the `lifecycle_mode` input defaults to
|
||||
# "plan" — the lifecycle scripts run `run_platform.sh --plan-only` (fast,
|
||||
# no AWS mutation, validates the contract->resolver->adapter->plan chain
|
||||
# for every module on every PR, with no AWS credentials or cost). Set to
|
||||
# "full" via workflow_dispatch (or the NOVA_LIFECYCLE_MODE repo variable)
|
||||
# to run the real apply→modify→destroy against live AWS. In plan mode the
|
||||
# short-lived CI VPC apply/destroy jobs are skipped (nothing is applied).
|
||||
#
|
||||
# A short-lived CI VPC (terraform/ci-vpc/) is created before testing VPC-dependent
|
||||
# modules (alb, ecs-service, rds, uptime, and L2 microservice) and destroyed
|
||||
# after all tests complete. The CI VPC is separate from the long-lived platform
|
||||
# VPC. Outputs are read from the S3 state by each lifecycle job (no artifact
|
||||
# passing needed).
|
||||
name: acdl-modules-lifecycle
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
lifecycle_mode:
|
||||
description: "Lifecycle mode: 'plan' (default, fast, no AWS mutation) or 'full' (real apply→modify→destroy against live AWS)"
|
||||
required: false
|
||||
default: "plan"
|
||||
type: choice
|
||||
options:
|
||||
- plan
|
||||
- full
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Prerequisite: apply the short-lived CI VPC (needed by VPC-dependent L1s + L2 microservice)
|
||||
# Skipped in plan mode (no resources are applied, so no VPC is needed).
|
||||
ci-vpc-apply:
|
||||
name: CI VPC apply
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
- name: Apply CI VPC
|
||||
working-directory: terraform/ci-vpc
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
terraform init -input=false -lock=false
|
||||
terraform apply -auto-approve -lock=false
|
||||
|
||||
# L1 lifecycle matrix: apply simple → apply complex (modify) → destroy
|
||||
lifecycle:
|
||||
name: L1 lifecycle (${{ matrix.module }})
|
||||
needs: ci-vpc-apply
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
module: [s3, kms-key, ecr, ecs-cluster, iam-role, cloudfront, waf, vpc, alb, ecs-service, rds, uptime]
|
||||
env:
|
||||
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
|
||||
sudo apt-get clean
|
||||
df -h /
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install dependencies
|
||||
run: pip install jsonschema pyyaml boto3
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
- name: Read CI VPC outputs
|
||||
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
|
||||
working-directory: terraform/ci-vpc
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
terraform init -input=false -lock=false
|
||||
terraform output -json > /tmp/ci-vpc-outputs.json
|
||||
- name: Apply (simple)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
|
||||
- name: Modify (complex)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
|
||||
- name: Destroy
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
|
||||
|
||||
# L2 lifecycle matrix: apply simple → apply complex (modify) → destroy
|
||||
l2-lifecycle:
|
||||
name: L2 lifecycle (${{ matrix.module }})
|
||||
needs: ci-vpc-apply
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
module: [static-assets, microservice]
|
||||
env:
|
||||
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
|
||||
sudo apt-get clean
|
||||
df -h /
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install dependencies
|
||||
run: pip install jsonschema pyyaml boto3
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
- name: Read CI VPC outputs
|
||||
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
|
||||
working-directory: terraform/ci-vpc
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
terraform init -input=false -lock=false
|
||||
terraform output -json > /tmp/ci-vpc-outputs.json
|
||||
- name: Apply (simple)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
|
||||
- name: Modify (complex)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
|
||||
- name: Destroy
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_l2_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
|
||||
|
||||
# Cleanup: destroy the CI VPC (always runs in full mode, even if lifecycle fails)
|
||||
ci-vpc-destroy:
|
||||
name: CI VPC destroy
|
||||
needs: [lifecycle, l2-lifecycle]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
- name: Destroy CI VPC
|
||||
working-directory: terraform/ci-vpc
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
terraform init -input=false -lock=false
|
||||
terraform destroy -auto-approve -lock=false
|
||||
Reference in New Issue
Block a user