Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93ae9e4a39 | |||
| d3179fff37 | |||
| c029b102a3 | |||
| 2806c6c3ed | |||
| e048acd4dd | |||
| 9421442afd |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# ACDL Adapters
|
# Nova Adapters
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Kyverno Adapter
|
# Kyverno Adapter
|
||||||
|
|
||||||
The Kyverno adapter translates Kyverno `PolicyReport` results to the
|
The Kyverno adapter translates Kyverno `PolicyReport` results to the
|
||||||
normalized ACDL
|
normalized Nova
|
||||||
[`PolicyCheckResult`](../../schemas/policy_check_result.schema.json) schema
|
[`PolicyCheckResult`](../../schemas/policy_check_result.schema.json) schema
|
||||||
(engine: `"kyverno"`), mirroring the Checkov/Wiz adapter pattern.
|
(engine: `"kyverno"`), mirroring the Checkov/Wiz adapter pattern.
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ publishes results to `PolicyReport` resources.
|
|||||||
## When to use it
|
## When to use it
|
||||||
|
|
||||||
Kyverno is the right engine **when the platform emits Kubernetes
|
Kyverno is the right engine **when the platform emits Kubernetes
|
||||||
manifests** (a K8s-native stack). The ACDL platform today emits Terraform
|
manifests** (a K8s-native stack). The Nova platform today emits Terraform
|
||||||
only (D-053), so this adapter is **ready but inactive**: it ships now so
|
only (D-053), so this adapter is **ready but inactive**: it ships now so
|
||||||
the schema path, severity/result mapping and sample policies are in place
|
the schema path, severity/result mapping and sample policies are in place
|
||||||
ahead of the GitOps reconciler that will emit K8s manifests (roadmap).
|
ahead of the GitOps reconciler that will emit K8s manifests (roadmap).
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Kyverno adapter — translate Kyverno PolicyReport results to ACDL PolicyCheckResult records.
|
"""Kyverno adapter — translate Kyverno PolicyReport results to Nova PolicyCheckResult records.
|
||||||
|
|
||||||
Kyverno is a Kubernetes-native policy engine. It evaluates K8s manifests
|
Kyverno is a Kubernetes-native policy engine. It evaluates K8s manifests
|
||||||
and produces PolicyReport resources. This adapter translates those results
|
and produces PolicyReport resources. This adapter translates those results
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""ACDL Terraform adapter — stateless assembler (v1.11 RESTART, P56a).
|
"""Nova Terraform adapter — stateless assembler (v1.11 RESTART, P56a).
|
||||||
|
|
||||||
A STATELESS ASSEMBLER. It owns no module content — no resource shape, no
|
A STATELESS ASSEMBLER. It owns no module content — no resource shape, no
|
||||||
nested HCL blocks, no defaults, no type-specific logic. It reads the
|
nested HCL blocks, no defaults, no type-specific logic. It reads the
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Wiz adapter — translate Wiz API results to ACDL PolicyCheckResult records.
|
"""Wiz adapter — translate Wiz API results to Nova PolicyCheckResult records.
|
||||||
|
|
||||||
Wiz is a SaaS security platform with a GraphQL API. This adapter
|
Wiz is a SaaS security platform with a GraphQL API. This adapter
|
||||||
translates Wiz issue records to the normalized PolicyCheckResult schema
|
translates Wiz issue records to the normalized PolicyCheckResult schema
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""ACDL Confidence Signal (REQ-19).
|
"""Nova Confidence Signal (REQ-19).
|
||||||
|
|
||||||
The platform's certified answer to "is this safe to proceed?" (vision
|
The platform's certified answer to "is this safe to proceed?" (vision
|
||||||
tenet: "Safety is Computed, Not Assumed"). Every delivery action produces
|
tenet: "Safety is Computed, Not Assumed"). Every delivery action produces
|
||||||
|
|||||||
+16
-26
@@ -1,4 +1,4 @@
|
|||||||
"""ACDL Contract Resolver — resolve a consumer contract to a Target Stack instance.
|
"""Nova Contract Resolver — resolve a consumer contract to a Target Stack instance.
|
||||||
|
|
||||||
The contract resolver is the bridge between the consumer's declared intent
|
The contract resolver is the bridge between the consumer's declared intent
|
||||||
(a contract YAML) and the platform's executable representation (a Target
|
(a contract YAML) and the platform's executable representation (a Target
|
||||||
@@ -50,22 +50,13 @@ from core import env
|
|||||||
def _load_env(env_name, repo_root):
|
def _load_env(env_name, repo_root):
|
||||||
"""Load the environment onboarding JSON for env_name.
|
"""Load the environment onboarding JSON for env_name.
|
||||||
|
|
||||||
Mirrors core.environment_check.load() but is self-contained so the
|
P7 (REQ-171): delegates to core.environment_check.load() (dedup —
|
||||||
resolver works both as a package import (`from core.contract_resolver
|
the two were verbatim duplicates). The environment_check module is
|
||||||
import resolve`) and as a script (`python3 core/contract_resolver.py`).
|
in the same core/ package, so the import works both as a package
|
||||||
Emits a stderr warning when account_id is the placeholder and env != dev.
|
import and as a script (`python3 core/contract_resolver.py`).
|
||||||
"""
|
"""
|
||||||
env_file = os.path.join(repo_root, "core", "environments", f"{env_name}.json")
|
from core import environment_check
|
||||||
if not os.path.isfile(env_file):
|
return environment_check.load(env_name, root=repo_root)
|
||||||
raise FileNotFoundError(f"no environment file for '{env_name}' at {env_file}")
|
|
||||||
env = _load_json(env_file)
|
|
||||||
if env.get("account_id") == "000000000000" and env_name != "dev":
|
|
||||||
sys.stderr.write(
|
|
||||||
f"WARNING: environment '{env_name}' has the placeholder account_id "
|
|
||||||
f"000000000000 — replace it with the real {env_name} account id "
|
|
||||||
f"before deploying (onboarding scaffold).\n"
|
|
||||||
)
|
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
def _load_json(path):
|
def _load_json(path):
|
||||||
@@ -471,7 +462,7 @@ def resolve(contract_path, repo_root=None, environment_override=None):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
contract_path: Path to the contract YAML file.
|
contract_path: Path to the contract YAML file.
|
||||||
repo_root: Root of the ACDL repo (defaults to two levels up from this file).
|
repo_root: Root of the Nova repo (defaults to two levels up from this file).
|
||||||
environment_override: When set (dev/qa/prod/dr), overrides the
|
environment_override: When set (dev/qa/prod/dr), overrides the
|
||||||
contract's 'environment' field BEFORE schema validation, so
|
contract's 'environment' field BEFORE schema validation, so
|
||||||
interpolation context is consistent (D-088). Used by
|
interpolation context is consistent (D-088). Used by
|
||||||
@@ -534,10 +525,14 @@ def resolve(contract_path, repo_root=None, environment_override=None):
|
|||||||
f"module '{module_name}' version '{version}' not found in registry")
|
f"module '{module_name}' version '{version}' not found in registry")
|
||||||
module_inputs = module_entry.get("inputs", {})
|
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]
|
entry = registry[module_name][version]
|
||||||
interface_path = entry["interface"]
|
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:
|
if is_l2:
|
||||||
fragment = _resolve_l2(module_name, version, module_inputs,
|
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", {}))
|
merged_outputs.update(fragment.get("outputs", {}))
|
||||||
all_resources.extend(fragment["resources"])
|
all_resources.extend(fragment["resources"])
|
||||||
|
|
||||||
# Determine stack kind: L2 if any module is L2 or if multi-module
|
# Determine stack kind: L2 if any module is L2 or if multi-module (P7)
|
||||||
if multi_module:
|
kind = "l2" if (multi_module or any_l2) else "l1"
|
||||||
kind = "l2"
|
|
||||||
elif any_l2:
|
|
||||||
kind = "l2"
|
|
||||||
else:
|
|
||||||
kind = "l1"
|
|
||||||
|
|
||||||
stack_instance = {
|
stack_instance = {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ def load(env_name, root=None):
|
|||||||
|
|
||||||
def _onboarding_message(env_name):
|
def _onboarding_message(env_name):
|
||||||
return (
|
return (
|
||||||
"=== ACDL Environment Onboarding ===\n"
|
"=== Nova Environment Onboarding ===\n"
|
||||||
f"No environment named '{env_name}' is bound to this repository.\n\n"
|
f"No environment named '{env_name}' is bound to this repository.\n\n"
|
||||||
"ACDL environments are platform-managed. The platform provisions on\n"
|
"Nova environments are platform-managed. The platform provisions on\n"
|
||||||
"your behalf:\n"
|
"your behalf:\n"
|
||||||
" - an AWS account (or a scoped partition of one)\n"
|
" - an AWS account (or a scoped partition of one)\n"
|
||||||
" - a network (VPC + subnets)\n"
|
" - a network (VPC + subnets)\n"
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ def _report_error(payload):
|
|||||||
raise RuntimeError(f"failed to read GitHub token from Secrets Manager: {e}")
|
raise RuntimeError(f"failed to read GitHub token from Secrets Manager: {e}")
|
||||||
|
|
||||||
owner, repo = PLATFORM_REPO.split("/")
|
owner, repo = PLATFORM_REPO.split("/")
|
||||||
title = f"[ACDL-ALERT] Deploy failure: {consumer_repo} / {contract_id}"
|
title = f"[NOVA-ALERT] Deploy failure: {consumer_repo} / {contract_id}"
|
||||||
|
|
||||||
# Check for an existing open issue with the same title (idempotency)
|
# Check for an existing open issue with the same title (idempotency)
|
||||||
# URL-encode the contract_id to prevent search-query injection (P1-1).
|
# URL-encode the contract_id to prevent search-query injection (P1-1).
|
||||||
@@ -188,7 +188,7 @@ def _report_error(payload):
|
|||||||
{stack_trace}
|
{stack_trace}
|
||||||
```
|
```
|
||||||
|
|
||||||
_This issue was auto-created by the ACDL platform Lambda (D-055). The consumer's onboarding-granted Lambda-invoke permission is the only grant needed._
|
_This issue was auto-created by the Nova platform Lambda (D-055). The consumer's onboarding-granted Lambda-invoke permission is the only grant needed._
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ evidence event) runs end-to-end against the local tier with no AWS:
|
|||||||
Each adapter exposes the same interface as the live counterpart so the
|
Each adapter exposes the same interface as the live counterpart so the
|
||||||
caller code path is unchanged; only the I/O target swaps. Selection is
|
caller code path is unchanged; only the I/O target swaps. Selection is
|
||||||
gated on the NOVA_LOCAL_TIER env var (set by run_platform.sh --local).
|
gated on the NOVA_LOCAL_TIER env var (set by run_platform.sh --local).
|
||||||
Dual-read via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.
|
Env vars read via core/env.py (NOVA_* only; the ACDL_* fallback was
|
||||||
|
removed in v1.15 P5, REQ-164).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -68,7 +69,7 @@ class FlatFileOutbox:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, dir: Optional[Path] = None) -> "FlatFileOutbox":
|
def create(cls, dir: Optional[Path] = None) -> "FlatFileOutbox":
|
||||||
d = Path(dir) if dir else Path(tempfile.mkdtemp(prefix="acdl_outbox_"))
|
d = Path(dir) if dir else Path(tempfile.mkdtemp(prefix="nova_outbox_"))
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
out = cls(dir=d)
|
out = cls(dir=d)
|
||||||
# Re-read the chain tail if the file already exists.
|
# Re-read the chain tail if the file already exists.
|
||||||
@@ -249,7 +250,7 @@ class LocalS3StateBackend:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, dir: Optional[Path] = None) -> "LocalS3StateBackend":
|
def create(cls, dir: Optional[Path] = None) -> "LocalS3StateBackend":
|
||||||
d = Path(dir) if dir else Path(tempfile.mkdtemp(prefix="acdl_tfstate_"))
|
d = Path(dir) if dir else Path(tempfile.mkdtemp(prefix="nova_tfstate_"))
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
return cls(state_dir=d)
|
return cls(state_dir=d)
|
||||||
|
|
||||||
@@ -499,9 +500,8 @@ def run_local_e2e(contract_path: str, repo_root: Optional[Path] = None) -> Dict[
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
contract = sys.argv[1] if len(sys.argv) > 1 else "contracts/microservice.yml"
|
contract = sys.argv[1] if len(sys.argv) > 1 else "contracts/microservice.yml"
|
||||||
# Set both so the dual-read in is_local_tier() finds NOVA_* (preferred);
|
# Set so is_local_tier() finds NOVA_LOCAL_TIER (NOVA_* only; the
|
||||||
# the ACDL_* alias stays for any unmigrated reader until P5.
|
# ACDL_* alias was removed in v1.15 P5, REQ-164).
|
||||||
os.environ["NOVA_LOCAL_TIER"] = "1"
|
os.environ["NOVA_LOCAL_TIER"] = "1"
|
||||||
# P5 (REQ-164): ACDL_LOCAL_TIER legacy alias removed (NOVA_* only)
|
|
||||||
result = run_local_e2e(contract)
|
result = run_local_e2e(contract)
|
||||||
print(json.dumps(result, indent=2))
|
print(json.dumps(result, indent=2))
|
||||||
@@ -17,11 +17,15 @@ existing /acdl/... parameters to /nova/... and deletes the old ones.)
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import boto3
|
import boto3
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
except ImportError:
|
except ImportError:
|
||||||
boto3 = None
|
boto3 = None
|
||||||
|
ClientError = Exception # type: ignore[assignment,misc]
|
||||||
|
|
||||||
# Repo root on sys.path so `from core import env` resolves to THIS package
|
# Repo root on sys.path so `from core import env` resolves to THIS package
|
||||||
# when run as a script (avoids editable-installed third-party `core` shadow).
|
# when run as a script (avoids editable-installed third-party `core` shadow).
|
||||||
@@ -109,10 +113,12 @@ def publish_to_ssm(outputs, environment, contract_id):
|
|||||||
Overwrite=True,
|
Overwrite=True,
|
||||||
)
|
)
|
||||||
results[name] = param_name
|
results[name] = param_name
|
||||||
except Exception as e:
|
except (ClientError, OSError) as e:
|
||||||
# Don't fail the pipeline if one output fails to publish, but log it
|
# P4 (REQ-168): narrow from bare `except Exception` to AWS +
|
||||||
|
# OS errors. Don't fail the pipeline if one output fails to
|
||||||
|
# publish, but log it with context.
|
||||||
import sys
|
import sys
|
||||||
print(f"WARNING: SSM put_parameter failed for {name}: {e}", file=sys.stderr)
|
print(f"WARNING: SSM put_parameter failed for {name}: {type(e).__name__}: {e}", file=sys.stderr)
|
||||||
results[name] = None
|
results[name] = None
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -171,7 +177,6 @@ def post_github_comment(comment_text, token=None, repo=None, pr_number=None):
|
|||||||
if not token or not repo or not pr_number:
|
if not token or not repo or not pr_number:
|
||||||
return False # not in a PR context or no token
|
return False # not in a PR context or no token
|
||||||
try:
|
try:
|
||||||
import urllib.request
|
|
||||||
url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments"
|
url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments"
|
||||||
data = json.dumps({"body": comment_text}).encode()
|
data = json.dumps({"body": comment_text}).encode()
|
||||||
req = urllib.request.Request(url, data=data, method="POST")
|
req = urllib.request.Request(url, data=data, method="POST")
|
||||||
@@ -179,9 +184,12 @@ def post_github_comment(comment_text, token=None, repo=None, pr_number=None):
|
|||||||
req.add_header("Accept", "application/vnd.github+json")
|
req.add_header("Accept", "application/vnd.github+json")
|
||||||
urllib.request.urlopen(req, timeout=10)
|
urllib.request.urlopen(req, timeout=10)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except (OSError, urllib.error.URLError, urllib.error.HTTPError) as e:
|
||||||
|
# P4 (REQ-168): narrow from bare `except Exception` to network +
|
||||||
|
# HTTP errors. Don't fail the pipeline if the PR comment can't be
|
||||||
|
# posted, but log it with context.
|
||||||
import sys
|
import sys
|
||||||
print(f"WARNING: GitHub PR comment failed: {e}", file=sys.stderr)
|
print(f"WARNING: GitHub PR comment failed: {type(e).__name__}: {e}", file=sys.stderr)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+58
-76
@@ -146,14 +146,18 @@ def _check_environment_schema_validation() -> Tuple[Status, str]:
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
def _check_resolver_static_assets() -> Tuple[Status, str]:
|
def _check_resolver(contract_path: str) -> Tuple[Status, str]:
|
||||||
"""CAP-003: contract_resolver resolves static-assets to a Target Stack."""
|
"""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:
|
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as t:
|
||||||
out = t.name
|
out = t.name
|
||||||
try:
|
try:
|
||||||
return _check_subprocess([
|
return _check_subprocess([
|
||||||
"python3", "core/contract_resolver.py",
|
"python3", "core/contract_resolver.py",
|
||||||
"contracts/static-assets.yml", out,
|
contract_path, out,
|
||||||
])
|
])
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -162,25 +166,19 @@ def _check_resolver_static_assets() -> Tuple[Status, str]:
|
|||||||
pass
|
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]:
|
def _check_resolver_microservice() -> Tuple[Status, str]:
|
||||||
"""CAP-004: contract_resolver resolves the microservice contract."""
|
"""CAP-004: contract_resolver resolves the microservice contract."""
|
||||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as t:
|
return _check_resolver("contracts/microservice.yml")
|
||||||
out = t.name
|
|
||||||
try:
|
|
||||||
return _check_subprocess([
|
|
||||||
"python3", "core/contract_resolver.py",
|
|
||||||
"contracts/microservice.yml", out,
|
|
||||||
])
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.unlink(out)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _check_adapter_emits_terraform() -> Tuple[Status, str]:
|
def _check_adapter_emits_terraform() -> Tuple[Status, str]:
|
||||||
"""CAP-005: terraform adapter compiles a resolved stack to .tf files."""
|
"""CAP-005: terraform adapter compiles a resolved stack to .tf files."""
|
||||||
work = tempfile.mkdtemp(prefix="acdl_regr_")
|
work = tempfile.mkdtemp(prefix="nova_regr_")
|
||||||
stack_path = os.path.join(work, "stack.json")
|
stack_path = os.path.join(work, "stack.json")
|
||||||
tf_dir = os.path.join(work, "tf")
|
tf_dir = os.path.join(work, "tf")
|
||||||
os.makedirs(tf_dir, exist_ok=True)
|
os.makedirs(tf_dir, exist_ok=True)
|
||||||
@@ -211,7 +209,7 @@ def _check_interpolation() -> Tuple[Status, str]:
|
|||||||
"import sys; sys.path.insert(0,'.'); "
|
"import sys; sys.path.insert(0,'.'); "
|
||||||
"from core.contract_resolver import _expand_vars; "
|
"from core.contract_resolver import _expand_vars; "
|
||||||
"ctx={'env':{'environment':'qa','account_id':'123'},'contract':{'id':'assets'}}; "
|
"ctx={'env':{'environment':'qa','account_id':'123'},'contract':{'id':'assets'}}; "
|
||||||
"assert _expand_vars('acdl-${env.environment}-${contract.id}', ctx)=='acdl-qa-assets'; "
|
"assert _expand_vars('nova-${env.environment}-${contract.id}', ctx)=='nova-qa-assets'; "
|
||||||
"print('interpolation ok')",
|
"print('interpolation ok')",
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -231,7 +229,7 @@ def _check_confidence_signal() -> Tuple[Status, str]:
|
|||||||
|
|
||||||
def _check_outbox_writer() -> Tuple[Status, str]:
|
def _check_outbox_writer() -> Tuple[Status, str]:
|
||||||
"""CAP-008: outbox_writer writes a hash-chained event to a temp file."""
|
"""CAP-008: outbox_writer writes a hash-chained event to a temp file."""
|
||||||
work = tempfile.mkdtemp(prefix="acdl_outbox_")
|
work = tempfile.mkdtemp(prefix="nova_outbox_")
|
||||||
event_path = os.path.join(work, "event.json")
|
event_path = os.path.join(work, "event.json")
|
||||||
event = {
|
event = {
|
||||||
"contractId": "regression-test", "eventType": "CONFIDENCE_COMPUTED",
|
"contractId": "regression-test", "eventType": "CONFIDENCE_COMPUTED",
|
||||||
@@ -315,7 +313,7 @@ def _load_aws_env() -> Dict[str, str]:
|
|||||||
continue
|
continue
|
||||||
if "=" in line:
|
if "=" in line:
|
||||||
k, v = line.split("=", 1)
|
k, v = line.split("=", 1)
|
||||||
# P5 (REQ-164): dual-read fallback removed — NOVA_* only.
|
# NOVA_* only (ACDL_* fallback removed in v1.15 P5, REQ-164).
|
||||||
if k == "NOVA_AWS_ACCESS_KEY_ID":
|
if k == "NOVA_AWS_ACCESS_KEY_ID":
|
||||||
env["AWS_ACCESS_KEY_ID"] = v
|
env["AWS_ACCESS_KEY_ID"] = v
|
||||||
elif k == "NOVA_AWS_SECRET_ACCESS_KEY":
|
elif k == "NOVA_AWS_SECRET_ACCESS_KEY":
|
||||||
@@ -325,21 +323,24 @@ def _load_aws_env() -> Dict[str, str]:
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
def _check_live_terraform_plan_microservice() -> Tuple[Status, str]:
|
def _check_live_terraform_plan(contract_path: str, label: str) -> Tuple[Status, str]:
|
||||||
"""CAP-013: terraform init+validate+plan against live AWS for the
|
"""Shared helper: terraform init+validate+plan against live AWS for a
|
||||||
microservice stack (D-093 live-AWS tier of the headline E2E).
|
contract (D-093 live-AWS tier of the headline E2E).
|
||||||
|
|
||||||
Requires AWS credentials (NOVA_AWS_ACCESS_KEY_ID etc. in .env.secrets;
|
Used by CAP-013 (microservice) and CAP-014 (static-assets) — the two
|
||||||
dual-read NOVA_* first, ACDL_* fallback per G-106).
|
were ~95% identical except the contract path + label (P5 dedup,
|
||||||
Runs in a temp dir; does NOT apply (plan only)."""
|
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
|
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")
|
stack_path = os.path.join(work, "stack.json")
|
||||||
tf_dir = os.path.join(work, "tf")
|
tf_dir = os.path.join(work, "tf")
|
||||||
os.makedirs(tf_dir, exist_ok=True)
|
os.makedirs(tf_dir, exist_ok=True)
|
||||||
rc, out, err = _run_subprocess([
|
rc, out, err = _run_subprocess([
|
||||||
"python3", "core/contract_resolver.py",
|
"python3", "core/contract_resolver.py",
|
||||||
"contracts/microservice.yml", stack_path,
|
contract_path, stack_path,
|
||||||
])
|
])
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return "Broken", f"resolver failed: {err.strip()[-200:]}"
|
return "Broken", f"resolver failed: {err.strip()[-200:]}"
|
||||||
@@ -366,47 +367,19 @@ def _check_live_terraform_plan_microservice() -> Tuple[Status, str]:
|
|||||||
)
|
)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return "Decayed", f"terraform plan failed: {err.strip()[-200:]}"
|
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]:
|
def _check_live_terraform_plan_static_assets() -> Tuple[Status, str]:
|
||||||
"""CAP-014: terraform init+validate+plan against live AWS for the
|
"""CAP-014: terraform init+validate+plan against live AWS for the
|
||||||
static-assets stack (CloudFront + WAF + S3)."""
|
static-assets stack (CloudFront + WAF + S3)."""
|
||||||
import tempfile, os
|
return _check_live_terraform_plan("contracts/static-assets.yml", "static-assets")
|
||||||
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)"
|
|
||||||
|
|
||||||
|
|
||||||
def _check_dynamodb_outbox_table() -> Tuple[Status, str]:
|
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)
|
["terraform", "fmt", "-check", "-diff", str(tf_dir)], timeout=30)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return "Broken", f"terraform fmt -check failed: {err.strip()[-200:]}"
|
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"]:
|
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():
|
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([
|
rc, out, err = _run_subprocess([
|
||||||
"python3", "core/contract_resolver.py", str(contract), "/dev/null",
|
"python3", "core/contract_resolver.py", str(contract), "/dev/null",
|
||||||
], timeout=30)
|
], timeout=30)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return "Broken", f"{ex}.yml resolver failed: {err.strip()[-200:]}"
|
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]:
|
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
|
This is an offline proxy, not live pipeline evidence; the live
|
||||||
apply/modify/destroy is verified by the modules-lifecycle workflow
|
apply/modify/destroy is verified by the modules-lifecycle workflow
|
||||||
run, not by this gate."""
|
run, not by this gate."""
|
||||||
for ex in ["simple", "complex"]:
|
module_dir = ROOT / "modules" / "l2" / module
|
||||||
contract = ROOT / "modules" / "l2" / module / "examples" / f"{ex}.yml"
|
status, detail = _assert_contracts_resolve(module_dir, "l2")
|
||||||
if not contract.is_file():
|
if status != "Verified":
|
||||||
return "Broken", f"modules/l2/{module}/examples/{ex}.yml missing"
|
return status, detail
|
||||||
rc, out, err = _run_subprocess([
|
return "Verified", "L2 composition resolves (simple + complex contracts; offline proxy)"
|
||||||
"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)"
|
|
||||||
|
|
||||||
|
|
||||||
def _check_cap_017_dynamodb() -> Tuple[Status, str]:
|
def _check_cap_017_dynamodb() -> Tuple[Status, str]:
|
||||||
|
|||||||
+28
-14
@@ -4,7 +4,8 @@
|
|||||||
"interface": "modules/l1/s3/interface.json",
|
"interface": "modules/l1/s3/interface.json",
|
||||||
"terraform_dir": "modules/l1/s3/terraform",
|
"terraform_dir": "modules/l1/s3/terraform",
|
||||||
"published_at": "2026-07-21T19:00:00Z",
|
"published_at": "2026-07-21T19:00:00Z",
|
||||||
"deprecated": false
|
"deprecated": false,
|
||||||
|
"kind": "l1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vpc": {
|
"vpc": {
|
||||||
@@ -12,7 +13,8 @@
|
|||||||
"interface": "modules/l1/vpc/interface.json",
|
"interface": "modules/l1/vpc/interface.json",
|
||||||
"published_at": "2026-07-21T21:30:00Z",
|
"published_at": "2026-07-21T21:30:00Z",
|
||||||
"deprecated": false,
|
"deprecated": false,
|
||||||
"terraform_dir": "modules/l1/vpc/terraform"
|
"terraform_dir": "modules/l1/vpc/terraform",
|
||||||
|
"kind": "l1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ecs-cluster": {
|
"ecs-cluster": {
|
||||||
@@ -20,7 +22,8 @@
|
|||||||
"interface": "modules/l1/ecs-cluster/interface.json",
|
"interface": "modules/l1/ecs-cluster/interface.json",
|
||||||
"published_at": "2026-07-21T21:30:00Z",
|
"published_at": "2026-07-21T21:30:00Z",
|
||||||
"deprecated": false,
|
"deprecated": false,
|
||||||
"terraform_dir": "modules/l1/ecs-cluster/terraform"
|
"terraform_dir": "modules/l1/ecs-cluster/terraform",
|
||||||
|
"kind": "l1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ecs-service": {
|
"ecs-service": {
|
||||||
@@ -28,7 +31,8 @@
|
|||||||
"interface": "modules/l1/ecs-service/interface.json",
|
"interface": "modules/l1/ecs-service/interface.json",
|
||||||
"published_at": "2026-07-21T21:30:00Z",
|
"published_at": "2026-07-21T21:30:00Z",
|
||||||
"deprecated": false,
|
"deprecated": false,
|
||||||
"terraform_dir": "modules/l1/ecs-service/terraform"
|
"terraform_dir": "modules/l1/ecs-service/terraform",
|
||||||
|
"kind": "l1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"iam-role": {
|
"iam-role": {
|
||||||
@@ -36,7 +40,8 @@
|
|||||||
"interface": "modules/l1/iam-role/interface.json",
|
"interface": "modules/l1/iam-role/interface.json",
|
||||||
"published_at": "2026-07-21T21:30:00Z",
|
"published_at": "2026-07-21T21:30:00Z",
|
||||||
"deprecated": false,
|
"deprecated": false,
|
||||||
"terraform_dir": "modules/l1/iam-role/terraform"
|
"terraform_dir": "modules/l1/iam-role/terraform",
|
||||||
|
"kind": "l1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"alb": {
|
"alb": {
|
||||||
@@ -44,7 +49,8 @@
|
|||||||
"interface": "modules/l1/alb/interface.json",
|
"interface": "modules/l1/alb/interface.json",
|
||||||
"published_at": "2026-07-21T21:30:00Z",
|
"published_at": "2026-07-21T21:30:00Z",
|
||||||
"deprecated": false,
|
"deprecated": false,
|
||||||
"terraform_dir": "modules/l1/alb/terraform"
|
"terraform_dir": "modules/l1/alb/terraform",
|
||||||
|
"kind": "l1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ecr": {
|
"ecr": {
|
||||||
@@ -52,7 +58,8 @@
|
|||||||
"interface": "modules/l1/ecr/interface.json",
|
"interface": "modules/l1/ecr/interface.json",
|
||||||
"published_at": "2026-07-21T21:30:00Z",
|
"published_at": "2026-07-21T21:30:00Z",
|
||||||
"deprecated": false,
|
"deprecated": false,
|
||||||
"terraform_dir": "modules/l1/ecr/terraform"
|
"terraform_dir": "modules/l1/ecr/terraform",
|
||||||
|
"kind": "l1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cloudfront": {
|
"cloudfront": {
|
||||||
@@ -60,7 +67,8 @@
|
|||||||
"interface": "modules/l1/cloudfront/interface.json",
|
"interface": "modules/l1/cloudfront/interface.json",
|
||||||
"published_at": "2026-07-22T19:00:00Z",
|
"published_at": "2026-07-22T19:00:00Z",
|
||||||
"deprecated": false,
|
"deprecated": false,
|
||||||
"terraform_dir": "modules/l1/cloudfront/terraform"
|
"terraform_dir": "modules/l1/cloudfront/terraform",
|
||||||
|
"kind": "l1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"waf": {
|
"waf": {
|
||||||
@@ -68,7 +76,8 @@
|
|||||||
"interface": "modules/l1/waf/interface.json",
|
"interface": "modules/l1/waf/interface.json",
|
||||||
"published_at": "2026-07-22T19:00:00Z",
|
"published_at": "2026-07-22T19:00:00Z",
|
||||||
"deprecated": false,
|
"deprecated": false,
|
||||||
"terraform_dir": "modules/l1/waf/terraform"
|
"terraform_dir": "modules/l1/waf/terraform",
|
||||||
|
"kind": "l1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rds": {
|
"rds": {
|
||||||
@@ -76,7 +85,8 @@
|
|||||||
"interface": "modules/l1/rds/interface.json",
|
"interface": "modules/l1/rds/interface.json",
|
||||||
"published_at": "2026-07-22T20:00:00Z",
|
"published_at": "2026-07-22T20:00:00Z",
|
||||||
"deprecated": false,
|
"deprecated": false,
|
||||||
"terraform_dir": "modules/l1/rds/terraform"
|
"terraform_dir": "modules/l1/rds/terraform",
|
||||||
|
"kind": "l1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"kms-key": {
|
"kms-key": {
|
||||||
@@ -84,7 +94,8 @@
|
|||||||
"interface": "modules/l1/kms-key/interface.json",
|
"interface": "modules/l1/kms-key/interface.json",
|
||||||
"published_at": "2026-07-22T20:00",
|
"published_at": "2026-07-22T20:00",
|
||||||
"deprecated": false,
|
"deprecated": false,
|
||||||
"terraform_dir": "modules/l1/kms-key/terraform"
|
"terraform_dir": "modules/l1/kms-key/terraform",
|
||||||
|
"kind": "l1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uptime": {
|
"uptime": {
|
||||||
@@ -92,21 +103,24 @@
|
|||||||
"interface": "modules/l1/uptime/interface.json",
|
"interface": "modules/l1/uptime/interface.json",
|
||||||
"published_at": "2026-07-22T21:00",
|
"published_at": "2026-07-22T21:00",
|
||||||
"deprecated": false,
|
"deprecated": false,
|
||||||
"terraform_dir": "modules/l1/uptime/terraform"
|
"terraform_dir": "modules/l1/uptime/terraform",
|
||||||
|
"kind": "l1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"static-assets": {
|
"static-assets": {
|
||||||
"1.0.0": {
|
"1.0.0": {
|
||||||
"interface": "modules/l2/static-assets/composition.json",
|
"interface": "modules/l2/static-assets/composition.json",
|
||||||
"published_at": "2026-07-22T15:00:00Z",
|
"published_at": "2026-07-22T15:00:00Z",
|
||||||
"deprecated": false
|
"deprecated": false,
|
||||||
|
"kind": "l2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"microservice": {
|
"microservice": {
|
||||||
"1.0.0": {
|
"1.0.0": {
|
||||||
"interface": "modules/l2/microservice/composition.json",
|
"interface": "modules/l2/microservice/composition.json",
|
||||||
"published_at": "2026-07-22T15:00:00Z",
|
"published_at": "2026-07-22T15:00:00Z",
|
||||||
"deprecated": false
|
"deprecated": false,
|
||||||
|
"kind": "l2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,8 +110,18 @@ def copy_one_param(client, source_name: str, dest_name: str, force: bool = False
|
|||||||
return "skipped-equal"
|
return "skipped-equal"
|
||||||
if not force:
|
if not force:
|
||||||
return "skipped-mismatch"
|
return "skipped-mismatch"
|
||||||
except Exception: # ParameterNotFound → proceed to put
|
except client.exceptions.ParameterNotFound:
|
||||||
pass
|
pass # target doesn't exist yet → proceed to put
|
||||||
|
except Exception as e:
|
||||||
|
# P4 (REQ-168): narrow the broad swallow — only ParameterNotFound
|
||||||
|
# is an expected "proceed to put" condition. Any other AWS error
|
||||||
|
# (auth, throttling, service) must surface, not be swallowed.
|
||||||
|
import sys
|
||||||
|
sys.stderr.write(
|
||||||
|
f"migrate_ssm_paths: get_parameter({dest_name}) failed: "
|
||||||
|
f"{type(e).__name__}: {e}\n"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
put_kwargs = {
|
put_kwargs = {
|
||||||
"Name": dest_name,
|
"Name": dest_name,
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ import json, sys
|
|||||||
stage = '''$STAGE'''
|
stage = '''$STAGE'''
|
||||||
status = '''$STATUS'''
|
status = '''$STATUS'''
|
||||||
details = json.loads('''$DETAILS''')
|
details = json.loads('''$DETAILS''')
|
||||||
lines = [f'### ACDL Stage: {stage} — {status}', '']
|
lines = [f'### Nova Stage: {stage} — {status}', '']
|
||||||
if details:
|
if details:
|
||||||
lines.append('| Metric | Value |')
|
lines.append('| Metric | Value |')
|
||||||
lines.append('|--------|-------|')
|
lines.append('|--------|-------|')
|
||||||
for k, v in details.items():
|
for k, v in details.items():
|
||||||
lines.append(f'| {k} | {v} |')
|
lines.append(f'| {k} | {v} |')
|
||||||
lines.append('')
|
lines.append('')
|
||||||
lines.append('> _Auto-posted by the ACDL deploy pipeline (D-055)._')
|
lines.append('> _Auto-posted by the Nova deploy pipeline (D-055)._')
|
||||||
print('\n'.join(lines))
|
print('\n'.join(lines))
|
||||||
")
|
")
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ banner() {
|
|||||||
|
|
||||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
echo "=== ACDL CI Pipeline (local reproduction) ==="
|
echo "=== Nova CI Pipeline (local reproduction) ==="
|
||||||
echo "contract: pipelines/ci.yml (3 stages)"
|
echo "contract: pipelines/ci.yml (3 stages)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
# parity with the L1 matrix, but $2 is accepted-but-ignored here (documented,
|
# parity with the L1 matrix, but $2 is accepted-but-ignored here (documented,
|
||||||
# not a bug).
|
# not a bug).
|
||||||
#
|
#
|
||||||
# Lifecycle mode (REQ-134): NOVA_LIFECYCLE_MODE (dual-read NOVA_* preferred,
|
# Lifecycle mode (REQ-134): NOVA_LIFECYCLE_MODE (NOVA_* only; ACDL_*
|
||||||
# ACDL_* fallback until P5) default "plan" = no-op
|
# fallback removed in v1.15 P5) default "plan" = no-op
|
||||||
# (plan mode never applies resources, so there is nothing to destroy).
|
# (plan mode never applies resources, so there is nothing to destroy).
|
||||||
# Set to "full" for the real `--destroy` against live AWS.
|
# Set to "full" for the real `--destroy` against live AWS.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -25,7 +25,7 @@ cd "$ROOT"
|
|||||||
MODULE="$1"
|
MODULE="$1"
|
||||||
|
|
||||||
# Lifecycle mode: "plan" (default) skips destroy; "full" runs the real destroy.
|
# Lifecycle mode: "plan" (default) skips destroy; "full" runs the real destroy.
|
||||||
# Dual-read: NOVA_* preferred, ACDL_* fallback (removed in P5).
|
# NOVA_* env vars only (ACDL_* fallback removed in v1.15 P5, REQ-164).
|
||||||
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-plan}"
|
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-plan}"
|
||||||
|
|
||||||
if [ "$LIFECYCLE_MODE" != "full" ]; then
|
if [ "$LIFECYCLE_MODE" != "full" ]; then
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
# positional args for parity with the L1 matrix, but $3 is accepted-but-
|
# positional args for parity with the L1 matrix, but $3 is accepted-but-
|
||||||
# ignored here (documented, not a bug).
|
# ignored here (documented, not a bug).
|
||||||
#
|
#
|
||||||
# Lifecycle mode (REQ-134): NOVA_LIFECYCLE_MODE (dual-read NOVA_* preferred,
|
# Lifecycle mode (REQ-134): NOVA_LIFECYCLE_MODE (NOVA_* only; ACDL_*
|
||||||
# ACDL_* fallback until P5) default "plan" runs
|
# fallback removed in v1.15 P5) default "plan" runs
|
||||||
# `run_platform.sh --plan-only` (fast, no AWS mutation). Set to "full" for
|
# `run_platform.sh --plan-only` (fast, no AWS mutation). Set to "full" for
|
||||||
# the real `--apply` against live AWS.
|
# the real `--apply` against live AWS.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -29,14 +29,12 @@ MODULE="$1"
|
|||||||
EXAMPLE="$2" # simple or complex
|
EXAMPLE="$2" # simple or complex
|
||||||
|
|
||||||
# Lifecycle mode: "plan" (default, fast) or "full" (real apply against AWS).
|
# Lifecycle mode: "plan" (default, fast) or "full" (real apply against AWS).
|
||||||
# Dual-read: NOVA_* preferred, ACDL_* fallback (removed in P5).
|
# NOVA_* env vars only (ACDL_* fallback removed in v1.15 P5, REQ-164).
|
||||||
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-plan}"
|
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-plan}"
|
||||||
|
|
||||||
CONTRACT="modules/l2/${MODULE}/examples/${EXAMPLE}.yml"
|
CONTRACT="modules/l2/${MODULE}/examples/${EXAMPLE}.yml"
|
||||||
|
|
||||||
# Point terraform_remote_state to the CI VPC state (not the platform VPC).
|
# Point terraform_remote_state to the CI VPC state (not the platform VPC).
|
||||||
# Set both NOVA_* (preferred by the dual-read helper) and ACDL_* (legacy
|
|
||||||
# fallback) so any unmigrated reader finds the key until P5.
|
|
||||||
export NOVA_REMOTE_STATE_KEY="spike/ci-vpc/terraform.tfstate"
|
export NOVA_REMOTE_STATE_KEY="spike/ci-vpc/terraform.tfstate"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
# For VPC-dependent modules, injects CI VPC outputs into the complex contract
|
# For VPC-dependent modules, injects CI VPC outputs into the complex contract
|
||||||
# before destroy (so terraform can find the resources in the right VPC).
|
# before destroy (so terraform can find the resources in the right VPC).
|
||||||
#
|
#
|
||||||
# Lifecycle mode (REQ-134): NOVA_LIFECYCLE_MODE (dual-read NOVA_* preferred,
|
# Lifecycle mode (REQ-134): NOVA_LIFECYCLE_MODE (NOVA_* only; ACDL_*
|
||||||
# ACDL_* fallback until P5) default "plan" = no-op
|
# fallback removed in v1.15 P5) default "plan" = no-op
|
||||||
# (plan mode never applies resources, so there is nothing to destroy; the
|
# (plan mode never applies resources, so there is nothing to destroy; the
|
||||||
# script exits 0 so the pipeline matrix cell stays green). Set to "full"
|
# script exits 0 so the pipeline matrix cell stays green). Set to "full"
|
||||||
# for the real `--destroy` against live AWS.
|
# for the real `--destroy` against live AWS.
|
||||||
@@ -20,7 +20,7 @@ CI_VPC_OUTPUTS="${2:-}"
|
|||||||
|
|
||||||
# Lifecycle mode: "plan" (default) skips destroy (nothing was applied);
|
# Lifecycle mode: "plan" (default) skips destroy (nothing was applied);
|
||||||
# "full" runs the real terraform destroy.
|
# "full" runs the real terraform destroy.
|
||||||
# Dual-read: NOVA_* preferred, ACDL_* fallback (removed in P5).
|
# NOVA_* env vars only (ACDL_* fallback removed in v1.15 P5, REQ-164).
|
||||||
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-plan}"
|
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-plan}"
|
||||||
|
|
||||||
if [ "$LIFECYCLE_MODE" != "full" ]; then
|
if [ "$LIFECYCLE_MODE" != "full" ]; then
|
||||||
@@ -33,7 +33,7 @@ CONTRACT="modules/l1/${MODULE}/examples/complex.yml"
|
|||||||
VPC_DEPENDENT="alb ecs-service rds uptime"
|
VPC_DEPENDENT="alb ecs-service rds uptime"
|
||||||
|
|
||||||
if echo "$VPC_DEPENDENT" | grep -qw "$MODULE" && [ -n "$CI_VPC_OUTPUTS" ] && [ -f "$CI_VPC_OUTPUTS" ]; then
|
if echo "$VPC_DEPENDENT" | grep -qw "$MODULE" && [ -n "$CI_VPC_OUTPUTS" ] && [ -f "$CI_VPC_OUTPUTS" ]; then
|
||||||
TMP_CONTRACT="/tmp/acdl-lifecycle-${MODULE}-complex.yml"
|
TMP_CONTRACT="/tmp/nova-lifecycle-${MODULE}-complex.yml"
|
||||||
python3 -c "
|
python3 -c "
|
||||||
import yaml, json
|
import yaml, json
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
# from the long-lived platform VPC.
|
# from the long-lived platform VPC.
|
||||||
#
|
#
|
||||||
# Lifecycle mode (REQ-134): the NOVA_LIFECYCLE_MODE env var selects the
|
# Lifecycle mode (REQ-134): the NOVA_LIFECYCLE_MODE env var selects the
|
||||||
# tier (dual-read NOVA_* preferred, ACDL_* fallback until P5). Default
|
# tier (NOVA_* only; ACDL_* fallback removed in v1.15 P5). Default
|
||||||
# "plan" runs `run_platform.sh --plan-only` (fast, no AWS
|
# "plan" runs `run_platform.sh --plan-only` (fast, no AWS
|
||||||
# mutation, validates the contract->resolver->adapter->plan chain for
|
# mutation, validates the contract->resolver->adapter->plan chain for
|
||||||
# every module). Set to "full" to run the real `--apply` (terraform apply
|
# every module). Set to "full" to run the real `--apply` (terraform apply
|
||||||
@@ -26,7 +26,7 @@ EXAMPLE="$2" # simple or complex
|
|||||||
CI_VPC_OUTPUTS="${3:-}"
|
CI_VPC_OUTPUTS="${3:-}"
|
||||||
|
|
||||||
# Lifecycle mode: "plan" (default, fast) or "full" (real apply against AWS).
|
# Lifecycle mode: "plan" (default, fast) or "full" (real apply against AWS).
|
||||||
# Dual-read: NOVA_* preferred, ACDL_* fallback (removed in P5).
|
# NOVA_* env vars only (ACDL_* fallback removed in v1.15 P5, REQ-164).
|
||||||
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-plan}"
|
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-plan}"
|
||||||
|
|
||||||
CONTRACT="modules/l1/${MODULE}/examples/${EXAMPLE}.yml"
|
CONTRACT="modules/l1/${MODULE}/examples/${EXAMPLE}.yml"
|
||||||
@@ -38,7 +38,7 @@ VPC_DEPENDENT="alb ecs-service rds uptime"
|
|||||||
# (only meaningful in full mode; plan mode ignores VPC outputs)
|
# (only meaningful in full mode; plan mode ignores VPC outputs)
|
||||||
if [ "$LIFECYCLE_MODE" = "full" ] && echo "$VPC_DEPENDENT" | grep -qw "$MODULE" && [ -n "$CI_VPC_OUTPUTS" ] && [ -f "$CI_VPC_OUTPUTS" ]; then
|
if [ "$LIFECYCLE_MODE" = "full" ] && echo "$VPC_DEPENDENT" | grep -qw "$MODULE" && [ -n "$CI_VPC_OUTPUTS" ] && [ -f "$CI_VPC_OUTPUTS" ]; then
|
||||||
# Generate a temporary contract with CI VPC outputs injected
|
# Generate a temporary contract with CI VPC outputs injected
|
||||||
TMP_CONTRACT="/tmp/acdl-lifecycle-${MODULE}-${EXAMPLE}.yml"
|
TMP_CONTRACT="/tmp/nova-lifecycle-${MODULE}-${EXAMPLE}.yml"
|
||||||
python3 -c "
|
python3 -c "
|
||||||
import yaml, json, sys
|
import yaml, json, sys
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ done
|
|||||||
CONTRACT="contracts/$MODULE.yaml"
|
CONTRACT="contracts/$MODULE.yaml"
|
||||||
[ -f "$CONTRACT" ] || { echo "FAIL: no sample contract at $CONTRACT for module '$MODULE'" >&2; exit 1; }
|
[ -f "$CONTRACT" ] || { echo "FAIL: no sample contract at $CONTRACT for module '$MODULE'" >&2; exit 1; }
|
||||||
|
|
||||||
WORK="/tmp/acdl_pattern_plan_$MODULE"
|
WORK="/tmp/nova_pattern_plan_$MODULE"
|
||||||
rm -rf "$WORK"; mkdir -p "$WORK"
|
rm -rf "$WORK"; mkdir -p "$WORK"
|
||||||
|
|
||||||
echo "=== Pattern plan: $MODULE ==="
|
echo "=== Pattern plan: $MODULE ==="
|
||||||
|
|||||||
+37
-54
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# scripts/run_platform.sh - the ACDL platform pipeline.
|
# scripts/run_platform.sh - the Nova platform pipeline.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# run_platform.sh <contract.yml> (full e2e with AWS)
|
# run_platform.sh <contract.yml> (full e2e with AWS)
|
||||||
@@ -113,6 +113,38 @@ fi
|
|||||||
|
|
||||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
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).
|
# --local: run the headline E2E against the local emulating tier (D-092).
|
||||||
# No AWS credentials, no Checkov, no DynamoDB. Emulates ECS, outbox, S3
|
# No AWS credentials, no Checkov, no DynamoDB. Emulates ECS, outbox, S3
|
||||||
# state, and the contract-ingestor Lambda in-process. Exits 0 on success.
|
# state, and the contract-ingestor Lambda in-process. Exits 0 on success.
|
||||||
@@ -142,15 +174,14 @@ stream() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
CONTRACT_ID="11111111-1111-1111-1111-111111111111" # spike fixed UUID
|
CONTRACT_ID="${NOVA_CONTRACT_ID:-11111111-1111-1111-1111-111111111111}" # spike UUID (override via NOVA_CONTRACT_ID)
|
||||||
WORK="/tmp/acdl_platform_run_v18"
|
WORK="${NOVA_WORK_DIR:-/tmp/nova_platform_run}"
|
||||||
TF_DIR="$WORK/tf"
|
TF_DIR="$WORK/tf"
|
||||||
rm -rf "$WORK"; mkdir -p "$TF_DIR"
|
rm -rf "$WORK"; mkdir -p "$TF_DIR"
|
||||||
|
|
||||||
echo "=== Step 0: environment onboarding check ==="
|
echo "=== Step 0: environment onboarding check ==="
|
||||||
if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
|
if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
|
||||||
export NOVA_ENVIRONMENT_OVERRIDE="$ENVIRONMENT_OVERRIDE"
|
export NOVA_ENVIRONMENT_OVERRIDE="$ENVIRONMENT_OVERRIDE"
|
||||||
export ACDL_ENVIRONMENT_OVERRIDE="$ENVIRONMENT_OVERRIDE" # legacy fallback, removed in P5
|
|
||||||
python3 core/environment_check.py --env="$ENVIRONMENT_OVERRIDE" || {
|
python3 core/environment_check.py --env="$ENVIRONMENT_OVERRIDE" || {
|
||||||
echo "FAIL: environment not bound — see the onboarding prompt above" >&2
|
echo "FAIL: environment not bound — see the onboarding prompt above" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -326,31 +357,7 @@ if [ "$APPLY_ONLY" = "1" ]; then
|
|||||||
if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
|
if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
|
||||||
RESOLVED_ENV="$ENVIRONMENT_OVERRIDE"
|
RESOLVED_ENV="$ENVIRONMENT_OVERRIDE"
|
||||||
fi
|
fi
|
||||||
if [ "$RESOLVED_ENV" != "dev" ]; then
|
run_hitl_gate "$CONTRACT_ID" "$RESOLVED_ENV" " before apply" || { echo "FAIL: HITL attestation gate blocked the apply" >&2; exit 1; }
|
||||||
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
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Step 5: terraform apply -auto-approve ==="
|
echo "=== Step 5: terraform apply -auto-approve ==="
|
||||||
@@ -442,31 +449,7 @@ RESOLVED_ENV=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONTRACT')).
|
|||||||
if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
|
if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
|
||||||
RESOLVED_ENV="$ENVIRONMENT_OVERRIDE"
|
RESOLVED_ENV="$ENVIRONMENT_OVERRIDE"
|
||||||
fi
|
fi
|
||||||
if [ "$RESOLVED_ENV" != "dev" ]; then
|
run_hitl_gate "$CONTRACT_ID" "$RESOLVED_ENV" "" || { echo "FAIL: HITL attestation gate blocked the promotion" >&2; exit 1; }
|
||||||
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
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Step 8: write evidence event to DynamoDB outbox ==="
|
echo "=== Step 8: write evidence event to DynamoDB outbox ==="
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ done
|
|||||||
INSTANCE="modules/l1/$PRIMITIVE/instance.json"
|
INSTANCE="modules/l1/$PRIMITIVE/instance.json"
|
||||||
[ -f "$INSTANCE" ] || { echo "FAIL: no instance.json for primitive '$PRIMITIVE'" >&2; exit 1; }
|
[ -f "$INSTANCE" ] || { echo "FAIL: no instance.json for primitive '$PRIMITIVE'" >&2; exit 1; }
|
||||||
|
|
||||||
WORK="/tmp/acdl_primitive_plan_$PRIMITIVE"
|
WORK="/tmp/nova_primitive_plan_$PRIMITIVE"
|
||||||
rm -rf "$WORK"; mkdir -p "$WORK"
|
rm -rf "$WORK"; mkdir -p "$WORK"
|
||||||
|
|
||||||
echo "=== Primitive plan: $PRIMITIVE ==="
|
echo "=== Primitive plan: $PRIMITIVE ==="
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
echo "=== Nova Regression VERIFY (D-091) ==="
|
echo "=== Nova Regression VERIFY (D-091) ==="
|
||||||
# Dual-read: NOVA_* preferred, ACDL_* fallback (removed in P5).
|
# NOVA_* env vars only (ACDL_* fallback removed in v1.15 P5, REQ-164).
|
||||||
echo "milestone: ${NOVA_REGRESSION_MILESTONE:-v1.10} phase: ${NOVA_REGRESSION_PHASE:-52}"
|
echo "milestone: ${NOVA_REGRESSION_MILESTONE:-v1.10} phase: ${NOVA_REGRESSION_PHASE:-52}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
|||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# scripts/ship_phase.sh — internal CIAgent per-phase ship helper (v1.16)
|
||||||
|
# Usage: bash scripts/ship_phase.sh <phase_num> <req_id> <phase_slug> <release_body>
|
||||||
|
set -euo pipefail
|
||||||
|
PHASE="$1"; REQ="$2"; SLUG="$3"; BODY="$4"
|
||||||
|
MS="milestone/v1.16-nova-simplification"
|
||||||
|
BR="phase/$(printf '%02d' "$PHASE")-${SLUG}"
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
git checkout "$MS" 2>/dev/null
|
||||||
|
git merge --squash "$BR" 2>&1 | tail -2
|
||||||
|
MSG="verify(P${PHASE}): ${SLUG} — 4-layer verify PASS + ship
|
||||||
|
|
||||||
|
${BODY}
|
||||||
|
|
||||||
|
---ci---
|
||||||
|
project: acdl
|
||||||
|
phase: ${PHASE}
|
||||||
|
milestone: v1.16
|
||||||
|
status: complete
|
||||||
|
phase_role: execution
|
||||||
|
requirements:
|
||||||
|
covered: [${REQ}]
|
||||||
|
partial: []
|
||||||
|
---/ci---"
|
||||||
|
git commit -q -m "$MSG"
|
||||||
|
PREV=$(git tag -l "v1.15.*" --sort=-version:refname | head -1)
|
||||||
|
PATCH=$(($(echo "$PREV" | sed 's/v1.15.//')))
|
||||||
|
NEWPATCH=$((PATCH + 1))
|
||||||
|
TAG="v1.15.${NEWPATCH}"
|
||||||
|
git tag -a "$TAG" -m "${TAG}: v1.16 P${PHASE} — ${SLUG}"
|
||||||
|
git push origin "$MS" --tags 2>&1 | grep -E "new tag|new branch" | head -2
|
||||||
|
python3 - "$TAG" "$PREV" <<'PYEOF'
|
||||||
|
import json, subprocess, sys, urllib.request, urllib.error
|
||||||
|
tag, prev = sys.argv[1], sys.argv[2]
|
||||||
|
tok = [l.split("=",1)[1].strip() for l in open(".env.secrets") if l.startswith("NOVA_GITEA_TOKEN=")][0]
|
||||||
|
body = subprocess.check_output(["git","log",f"{prev}..{tag}","--oneline"]).decode()
|
||||||
|
payload = {"tag_name":tag,"name":f"Nova {tag} — v1.16 P{tag.split('.')[-1]}","body":body}
|
||||||
|
req = urllib.request.Request("https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/acdl/releases", data=json.dumps(payload).encode(), headers={"Authorization":f"token {tok}","Content-Type":"application/json"}, method="POST")
|
||||||
|
try:
|
||||||
|
r = urllib.request.urlopen(req, timeout=30); d = json.loads(r.read()); print(f"release_id: {d.get('id')} tag: {tag}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 409: print(f"release exists for {tag}")
|
||||||
|
else: print(f"HTTP {e.code}: {e.read().decode()[:120]}")
|
||||||
|
except Exception as e: print(f"ERROR: {e}")
|
||||||
|
PYEOF
|
||||||
|
echo "SHIPPED ${TAG}"
|
||||||
@@ -78,6 +78,7 @@ def test_run_platform_sh_has_environment_flag():
|
|||||||
text = (ROOT / "scripts" / "run_platform.sh").read_text()
|
text = (ROOT / "scripts" / "run_platform.sh").read_text()
|
||||||
assert "--environment" in text
|
assert "--environment" in text
|
||||||
assert "ENVIRONMENT_OVERRIDE" in text
|
assert "ENVIRONMENT_OVERRIDE" in text
|
||||||
# P2 (REQ-159): NOVA_* preferred; ACDL_* kept as dual-read fallback until P5.
|
# P3 (REQ-167): NOVA_* only; the dead ACDL_ENVIRONMENT_OVERRIDE export
|
||||||
|
# (comment said "removed in P5" but the line was present) is gone.
|
||||||
assert "NOVA_ENVIRONMENT_OVERRIDE" in text
|
assert "NOVA_ENVIRONMENT_OVERRIDE" in text
|
||||||
assert "ACDL_ENVIRONMENT_OVERRIDE" in text # legacy fallback, removed in P5
|
assert "ACDL_ENVIRONMENT_OVERRIDE" not in text
|
||||||
@@ -31,6 +31,12 @@ class TestEnvironmentCheck:
|
|||||||
assert "state backend" in msg.lower()
|
assert "state backend" in msg.lower()
|
||||||
assert "IAM role" in msg
|
assert "IAM role" in msg
|
||||||
|
|
||||||
|
def test_onboarding_message_says_nova_not_acdl(self):
|
||||||
|
"""P2 (REQ-166): the onboarding message is rebranded Nova."""
|
||||||
|
msg = _onboarding_message("qa")
|
||||||
|
assert "Nova Environment Onboarding" in msg
|
||||||
|
assert "ACDL" not in msg
|
||||||
|
|
||||||
def test_contract_with_dev_environment_passes(self):
|
def test_contract_with_dev_environment_passes(self):
|
||||||
ok, msg = check(contract_path=str(ROOT / "contracts/static-assets.yml"), root=ROOT)
|
ok, msg = check(contract_path=str(ROOT / "contracts/static-assets.yml"), root=ROOT)
|
||||||
assert ok is True
|
assert ok is True
|
||||||
|
|||||||
@@ -74,4 +74,52 @@ class TestMapPath:
|
|||||||
|
|
||||||
def test_preserves_value_segment_exactly(self):
|
def test_preserves_value_segment_exactly(self):
|
||||||
# Hyphens, dots, underscores in output names are preserved
|
# Hyphens, dots, underscores in output names are preserved
|
||||||
assert map_path("/acdl/dev/c-1/my.output-name_2") == "/nova/dev/c-1/my.output-name_2"
|
assert map_path("/acdl/dev/c-1/my.output-name_2") == "/nova/dev/c-1/my.output-name_2"
|
||||||
|
|
||||||
|
class TestNarrowedException:
|
||||||
|
"""P4 (REQ-168): the copy_one_param except is narrowed to
|
||||||
|
ParameterNotFound; non-ParameterNotFound errors surface (not swallowed)."""
|
||||||
|
|
||||||
|
def test_parameter_not_found_proceeds_to_put(self):
|
||||||
|
"""A ParameterNotFound on the dest get_parameter (target absent) is
|
||||||
|
the expected 'proceed to put' path — not an error."""
|
||||||
|
from unittest import mock
|
||||||
|
import migrate_ssm_paths as m
|
||||||
|
|
||||||
|
class FakeExceptions:
|
||||||
|
ParameterNotFound = type("ParameterNotFound", (Exception,), {})
|
||||||
|
|
||||||
|
fake_client = mock.Mock()
|
||||||
|
fake_client.exceptions = FakeExceptions
|
||||||
|
# source get_parameter succeeds; dest get_parameter raises ParameterNotFound
|
||||||
|
fake_client.get_parameter.side_effect = [
|
||||||
|
{"Parameter": {"Value": "v", "Type": "String", "KeyId": None}},
|
||||||
|
FakeExceptions.ParameterNotFound(),
|
||||||
|
]
|
||||||
|
fake_client.put_parameter.return_value = {"Version": 1}
|
||||||
|
result = m.copy_one_param(fake_client, "/acdl/dev/c/out", "/nova/dev/c/out")
|
||||||
|
assert result == "copied"
|
||||||
|
fake_client.put_parameter.assert_called_once()
|
||||||
|
|
||||||
|
def test_non_parameter_not_found_error_is_raised(self):
|
||||||
|
"""A non-ParameterNotFound AWS error (e.g. ThrottlingException) on
|
||||||
|
the dest get_parameter is raised, not swallowed (P4, REQ-168)."""
|
||||||
|
from unittest import mock
|
||||||
|
import migrate_ssm_paths as m
|
||||||
|
|
||||||
|
class FakeExceptions:
|
||||||
|
ParameterNotFound = type("ParameterNotFound", (Exception,), {})
|
||||||
|
|
||||||
|
class ThrottlingException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
fake_client = mock.Mock()
|
||||||
|
fake_client.exceptions = FakeExceptions
|
||||||
|
# source get_parameter succeeds; dest get_parameter raises Throttling
|
||||||
|
fake_client.get_parameter.side_effect = [
|
||||||
|
{"Parameter": {"Value": "v", "Type": "String", "KeyId": None}},
|
||||||
|
ThrottlingException("slow down"),
|
||||||
|
]
|
||||||
|
with pytest.raises(ThrottlingException):
|
||||||
|
m.copy_one_param(fake_client, "/acdl/dev/c/out", "/nova/dev/c/out")
|
||||||
|
fake_client.put_parameter.assert_not_called()
|
||||||
|
|||||||
@@ -134,7 +134,11 @@ class TestPublishToSsm:
|
|||||||
def flaky_put(**kwargs):
|
def flaky_put(**kwargs):
|
||||||
call_count["n"] += 1
|
call_count["n"] += 1
|
||||||
if "bad" in kwargs["Name"]:
|
if "bad" in kwargs["Name"]:
|
||||||
raise Exception("simulated failure")
|
from botocore.exceptions import ClientError
|
||||||
|
raise ClientError(
|
||||||
|
{"Error": {"Code": "InternalError", "Message": "simulated"}},
|
||||||
|
"PutParameter",
|
||||||
|
)
|
||||||
return real_put(**kwargs)
|
return real_put(**kwargs)
|
||||||
|
|
||||||
with mock.patch("core.output_publisher._ssm_client", return_value=ssm):
|
with mock.patch("core.output_publisher._ssm_client", return_value=ssm):
|
||||||
@@ -272,10 +276,11 @@ class TestPostGithubComment:
|
|||||||
assert "/issues/5/comments" in captured["url"]
|
assert "/issues/5/comments" in captured["url"]
|
||||||
|
|
||||||
def test_returns_false_on_exception(self, monkeypatch):
|
def test_returns_false_on_exception(self, monkeypatch):
|
||||||
|
import urllib.error
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", "tok")
|
monkeypatch.setenv("GITHUB_TOKEN", "tok")
|
||||||
monkeypatch.setenv("GITHUB_REPOSITORY", "acdl/acdl")
|
monkeypatch.setenv("GITHUB_REPOSITORY", "acdl/acdl")
|
||||||
monkeypatch.setenv("GITHUB_REF", "refs/pull/1/merge")
|
monkeypatch.setenv("GITHUB_REF", "refs/pull/1/merge")
|
||||||
with mock.patch("urllib.request.urlopen", side_effect=Exception("boom")):
|
with mock.patch("urllib.request.urlopen", side_effect=urllib.error.URLError("boom")):
|
||||||
assert post_github_comment("body") is False
|
assert post_github_comment("body") is False
|
||||||
|
|
||||||
def test_uses_gh_token_fallback(self, monkeypatch):
|
def test_uses_gh_token_fallback(self, monkeypatch):
|
||||||
|
|||||||
Reference in New Issue
Block a user