Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec2311a3e0 | |||
| 4d694ba2e9 | |||
| 3d9dd06411 | |||
| b257846981 | |||
| 986171a165 | |||
| 099ed015ac | |||
| cc97a9308d | |||
| c2ca0e4631 | |||
| 225de0f613 | |||
| 69d8496107 | |||
| 1aa525f234 | |||
| 81f111d462 | |||
| 79e7a4a304 | |||
| 8ae307affc | |||
| 6e1a1bd7db | |||
| 040abc0fb7 |
@@ -93,22 +93,25 @@ down to zero-cost steady state (P64, D-096).
|
||||
|
||||
- **CAP-017 (Verified):** DynamoDB `acdl-contracts` table — Verified
|
||||
live-aws via L1 rds module lifecycle pipeline (apply/modify/destroy
|
||||
exit 0). Evidence: regression registry CAP-017 (lifecycle-pipeline tier).
|
||||
exit 0). Evidence: regression registry CAP-017 (offline proxy: terraform
|
||||
files present + fmt -check passes + contracts resolve; live
|
||||
apply/modify/destroy verified by the modules-lifecycle workflow run).
|
||||
- **CAP-018 (Verified):** Lambda contract-ingestor — Verified via local
|
||||
Lambda stub (CAP-011, Phase 53) + lifecycle pipeline. Evidence:
|
||||
regression registry CAP-018.
|
||||
regression registry CAP-018 (offline proxy).
|
||||
- **CAP-019 (Verified):** ECS cluster + service — Verified live-aws via
|
||||
L2 microservice lifecycle pipeline (apply/modify/destroy exit 0).
|
||||
Evidence: regression registry CAP-019.
|
||||
Evidence: regression registry CAP-019 (offline proxy).
|
||||
- **CAP-020 (Verified):** CloudFront + WAF production static-assets
|
||||
stack — Verified live-aws via L2 static-assets lifecycle pipeline
|
||||
(apply/modify/destroy exit 0). Evidence: regression registry CAP-020.
|
||||
(apply/modify/destroy exit 0). Evidence: regression registry CAP-020
|
||||
(offline proxy).
|
||||
- **CAP-021 (Verified):** uptime-kuma monitoring primitive — Verified
|
||||
live-aws via L1 uptime module lifecycle pipeline. Evidence: regression
|
||||
registry CAP-021.
|
||||
registry CAP-021 (offline proxy).
|
||||
- **CAP-022 (Verified):** OIDC role for act_runner — Verified live-aws
|
||||
via L1 iam-role module lifecycle pipeline. Evidence: regression
|
||||
registry CAP-022.
|
||||
registry CAP-022 (offline proxy).
|
||||
|
||||
All CAP-017..022 are now in the regression registry
|
||||
(`core/regression_verify.py`) with "lifecycle-pipeline" tier evidence
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"phase": 0,
|
||||
"stage": "grill",
|
||||
"stage": "complete",
|
||||
"milestone": "v1.14",
|
||||
"phase_role": "pre_execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-07-29T20:25:00Z"
|
||||
"updated_at": "2026-07-29T20:30:00Z"
|
||||
}
|
||||
@@ -37,14 +37,13 @@
|
||||
"escalate_high_severity": true,
|
||||
"bash_allowlist": {
|
||||
"allowed_commands": [
|
||||
"npm", "node", "npx", "pnpm", "yarn",
|
||||
"git", "ls", "cat", "head", "tail", "wc",
|
||||
"echo", "mkdir", "cp", "mv", "rm", "touch",
|
||||
"pwd", "which", "env", "printenv",
|
||||
"jest", "eslint", "tsc", "prettier",
|
||||
"python3", "pytest", "pip",
|
||||
"terraform", "checkov",
|
||||
"curl", "wget",
|
||||
"docker", "docker-compose",
|
||||
"ts-node", "tsx"
|
||||
"docker", "docker-compose"
|
||||
],
|
||||
"max_output_bytes": 1048576,
|
||||
"timeout_ms": 30000,
|
||||
|
||||
+11
-1
@@ -18,4 +18,14 @@ terraform/bootstrap/.bootstrap_state.json
|
||||
**/.terraform/
|
||||
**/.terraform.lock.hcl
|
||||
**/tfplan
|
||||
**/*.tfstate*
|
||||
**/*.tfstate*
|
||||
|
||||
# Credential patterns (v1.14, REQ-146)
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
*.cer
|
||||
*.crt
|
||||
*.jks
|
||||
*.keystore
|
||||
@@ -8,13 +8,16 @@ v1.9 (REQ-111): the translator is fleshed out — full PolicyReport →
|
||||
PolicyCheckResult mapping with severity + skip-with-reason handling. It
|
||||
remains inactive for Terraform-only stacks (guard preserved — emits a
|
||||
single SKIPPED `KYVERNO_INACTIVE_TF_STACK` record when no K8s manifests).
|
||||
A `--kube-version` stub is parsed but not yet used (for future GitOps).
|
||||
A `--kube-version` flag was previously parsed but never used. It has been
|
||||
removed (v1.14, G-103) to resolve the stub. Version-aware policy selection
|
||||
will be added when the GitOps reconciler emits K8s manifests (D-053
|
||||
roadmap). The adapter is inactive for Terraform-only stacks today.
|
||||
|
||||
D-053: the platform emits Terraform, not K8s manifests. This adapter
|
||||
activates when the GitOps reconciler (roadmap) emits K8s manifests.
|
||||
Sample policies are included as documentation at adapters/kyverno/policies/.
|
||||
|
||||
CLI: kyverno_adapter.py <policyreport.json> <contract-id> [--kube-version <ver>]
|
||||
CLI: kyverno_adapter.py <policyreport.json> <contract-id>
|
||||
"""
|
||||
|
||||
import datetime
|
||||
@@ -100,7 +103,7 @@ def _emit_inactive_tf(contract_id):
|
||||
}
|
||||
|
||||
|
||||
def adapt(policyreport_json_path, contract_id, kube_version=None):
|
||||
def adapt(policyreport_json_path, contract_id):
|
||||
with open(policyreport_json_path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
out = []
|
||||
@@ -112,8 +115,6 @@ def adapt(policyreport_json_path, contract_id, kube_version=None):
|
||||
out.append(_to_pcr(entry, contract_id))
|
||||
if not out:
|
||||
out.append(_emit_inactive_tf(contract_id))
|
||||
# kube_version is parsed but not yet used (future GitOps reconciler).
|
||||
_ = kube_version
|
||||
return out
|
||||
|
||||
|
||||
@@ -123,14 +124,8 @@ def adapt_inactive(contract_id):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
kube_ver = None
|
||||
args = sys.argv[1:]
|
||||
if "--kube-version" in args:
|
||||
idx = args.index("--kube-version")
|
||||
if idx + 1 < len(args):
|
||||
kube_ver = args[idx + 1]
|
||||
args = args[:idx] + args[idx + 2:]
|
||||
if len(args) != 2:
|
||||
print("usage: kyverno_adapter.py <policyreport.json> <contract-id> [--kube-version <ver>]", file=sys.stderr)
|
||||
print("usage: kyverno_adapter.py <policyreport.json> <contract-id>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
print(json.dumps(adapt(args[0], args[1], kube_version=kube_ver), indent=2))
|
||||
print(json.dumps(adapt(args[0], args[1]), indent=2))
|
||||
@@ -112,6 +112,8 @@ def adapt(stack_instance, out_dir):
|
||||
|
||||
stack_name = stack.get("name", "spike")
|
||||
environment = stack.get("environment", "dev")
|
||||
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
||||
state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
|
||||
terraform_tf = (
|
||||
'terraform {\n'
|
||||
' required_version = ">= 1.9, < 1.10"\n'
|
||||
@@ -122,7 +124,7 @@ def adapt(stack_instance, out_dir):
|
||||
' }\n'
|
||||
' }\n'
|
||||
' backend "s3" {\n'
|
||||
' bucket = "acdl-tfstate-581513795199-us-east-1"\n'
|
||||
f' bucket = "{state_bucket}"\n'
|
||||
f' key = "spike/{stack_name}/{environment}/terraform.tfstate"\n'
|
||||
' region = "us-east-1"\n'
|
||||
' }\n'
|
||||
@@ -137,7 +139,7 @@ def adapt(stack_instance, out_dir):
|
||||
'data "terraform_remote_state" "platform" {\n'
|
||||
' backend = "s3"\n'
|
||||
' config = {\n'
|
||||
' bucket = "acdl-tfstate-581513795199-us-east-1"\n'
|
||||
f' bucket = "{state_bucket}"\n'
|
||||
f' key = "{remote_state_key}"\n'
|
||||
' region = "us-east-1"\n'
|
||||
' }\n'
|
||||
|
||||
@@ -17,6 +17,7 @@ requests. The invoke policy is scoped via ABAC (consumer repo identity).
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
|
||||
import boto3
|
||||
@@ -154,7 +155,16 @@ def _report_error(payload):
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
search_result = json.loads(resp.read())
|
||||
existing = search_result.get("items", [])
|
||||
except Exception:
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
existing = []
|
||||
else:
|
||||
import sys
|
||||
print(f"WARNING: GitHub issue search failed (HTTP {e.code}): {e}", file=sys.stderr)
|
||||
existing = []
|
||||
except urllib.error.URLError as e:
|
||||
import sys
|
||||
print(f"WARNING: GitHub issue search network error: {e}", file=sys.stderr)
|
||||
existing = []
|
||||
|
||||
body = f"""## Deploy Failure Report
|
||||
@@ -228,21 +238,42 @@ def _validate_caller_identity(event, payload):
|
||||
|
||||
If the identity is not available (e.g. local testing or non-IAM auth), the
|
||||
check is skipped (the ABAC policy at the IAM layer enforces the scope).
|
||||
|
||||
v1.14 (REQ-144): also validates contractId format, environment enum, and
|
||||
error length. The ABAC reliance is documented here: the Function URL IAM
|
||||
identity does not expose principal tags in the event, so full enforcement
|
||||
of consumerRepo ownership is at the IAM layer (ABAC via
|
||||
aws:PrincipalTag/acdl:owner). This function validates format only, not
|
||||
ownership.
|
||||
"""
|
||||
identity = event.get("requestContext", {}).get("identity", {})
|
||||
caller_arn = identity.get("userArn", "")
|
||||
if not caller_arn:
|
||||
return # no identity available — rely on IAM ABAC enforcement
|
||||
pass # no identity available — rely on IAM ABAC enforcement
|
||||
payload_repo = payload.get("consumerRepo", "")
|
||||
if not payload_repo:
|
||||
return
|
||||
# Extract the session name or principal tag from the ARN. The ABAC policy
|
||||
# scopes via aws:PrincipalTag/acdl:owner = <consumerRepo>. The Function URL
|
||||
# IAM identity does not expose principal tags in the event, so we do a
|
||||
# best-effort check: the consumerRepo must not be empty and must be a valid
|
||||
# repo identifier (org/repo format). Full enforcement is at the IAM layer.
|
||||
if "/" not in payload_repo or len(payload_repo) > 128:
|
||||
raise ValueError(f"invalid consumerRepo format: {payload_repo!r}")
|
||||
if payload_repo:
|
||||
# consumerRepo must be org/repo format, <=128 chars
|
||||
if "/" not in payload_repo or len(payload_repo) > 128:
|
||||
raise ValueError(f"invalid consumerRepo format: {payload_repo!r}")
|
||||
|
||||
# v1.14 (REQ-144): contractId format validation
|
||||
contract_id = payload.get("contractId", "")
|
||||
if contract_id:
|
||||
import re
|
||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', contract_id):
|
||||
raise ValueError(f"invalid contractId format: {contract_id!r} (alphanumeric, hyphen, underscore; max 64 chars)")
|
||||
|
||||
# v1.14 (REQ-144): environment enum validation
|
||||
environment = payload.get("environment", "")
|
||||
if environment:
|
||||
valid_envs = {"dev", "qa", "prod", "dr"}
|
||||
if environment not in valid_envs:
|
||||
raise ValueError(f"invalid environment: {environment!r} (must be one of {valid_envs})")
|
||||
|
||||
# v1.14 (REQ-144): error length cap (for report_error action)
|
||||
error_msg = payload.get("error", "")
|
||||
if error_msg and len(str(error_msg)) > 10000:
|
||||
payload["error"] = str(error_msg)[:10000]
|
||||
|
||||
|
||||
def _validate_change_request(payload):
|
||||
|
||||
@@ -371,8 +371,9 @@ class LocalLambdaStub:
|
||||
return _FakeResponse(
|
||||
json.dumps([{"number": 1, "title": "stub"}]).encode())
|
||||
urllib.request.urlopen = _fake_urlopen
|
||||
except Exception:
|
||||
pass
|
||||
except (AttributeError, TypeError) as e:
|
||||
import sys
|
||||
print(f"WARNING: could not patch urlopen for local Lambda stub: {e}", file=sys.stderr)
|
||||
|
||||
try:
|
||||
event = {
|
||||
|
||||
@@ -97,8 +97,10 @@ def publish_to_ssm(outputs, environment, contract_id):
|
||||
Overwrite=True,
|
||||
)
|
||||
results[name] = param_name
|
||||
except Exception:
|
||||
# Don't fail the pipeline if one output fails to publish
|
||||
except Exception as e:
|
||||
# Don't fail the pipeline if one output fails to publish, but log it
|
||||
import sys
|
||||
print(f"WARNING: SSM put_parameter failed for {name}: {e}", file=sys.stderr)
|
||||
results[name] = None
|
||||
return results
|
||||
|
||||
@@ -165,7 +167,9 @@ def post_github_comment(comment_text, token=None, repo=None, pr_number=None):
|
||||
req.add_header("Accept", "application/vnd.github+json")
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
return True
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"WARNING: GitHub PR comment failed: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
+28
-12
@@ -421,8 +421,10 @@ def _check_s3_state_bucket() -> Tuple[Status, str]:
|
||||
s3 = boto3.client("s3", region_name=env.get("AWS_DEFAULT_REGION", "us-east-1"),
|
||||
aws_access_key_id=env.get("AWS_ACCESS_KEY_ID"),
|
||||
aws_secret_access_key=env.get("AWS_SECRET_ACCESS_KEY"))
|
||||
s3.head_bucket(Bucket="acdl-tfstate-581513795199-us-east-1")
|
||||
r = s3.list_objects_v2(Bucket="acdl-tfstate-581513795199-us-east-1", MaxKeys=5)
|
||||
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
||||
state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
|
||||
s3.head_bucket(Bucket=state_bucket)
|
||||
r = s3.list_objects_v2(Bucket=state_bucket, MaxKeys=5)
|
||||
keys = [o["Key"] for o in r.get("Contents", [])]
|
||||
return "Verified", f"state bucket exists, keys={keys}"
|
||||
except Exception as e:
|
||||
@@ -431,13 +433,19 @@ def _check_s3_state_bucket() -> Tuple[Status, str]:
|
||||
|
||||
def _check_lifecycle_module_terraform(module: str) -> Tuple[Status, str]:
|
||||
"""Helper: verify an L1 module's terraform dir exists with the required
|
||||
files + its example contracts resolve. This is the offline proxy for
|
||||
'lifecycle pipeline green' — the pipeline cell going green requires
|
||||
terraform init+validate+apply+modify+destroy to succeed against live
|
||||
AWS, which requires the terraform files to exist and contracts to
|
||||
resolve first. We avoid terraform init here (too slow for the
|
||||
regression gate); terraform validate is run by the lifecycle pipeline
|
||||
itself."""
|
||||
files + its example contracts resolve + terraform fmt syntax check
|
||||
passes. This is the offline proxy for 'lifecycle pipeline green' — the
|
||||
pipeline cell going green requires terraform init+validate+apply+modify+
|
||||
destroy to succeed against live AWS, which requires the terraform files
|
||||
to exist, contracts to resolve, and HCL syntax to be valid first.
|
||||
|
||||
We run `terraform fmt -check` (fast, no init required) as a syntax probe.
|
||||
We avoid `terraform validate` here (requires `terraform init`, which
|
||||
downloads providers — too slow for the regression gate). Full
|
||||
`terraform validate` is run by the lifecycle pipeline itself. 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."""
|
||||
tf_dir = ROOT / "modules" / "l1" / module / "terraform"
|
||||
if not tf_dir.is_dir():
|
||||
return "Broken", f"modules/l1/{module}/terraform/ does not exist"
|
||||
@@ -450,6 +458,11 @@ def _check_lifecycle_module_terraform(module: str) -> Tuple[Status, str]:
|
||||
tf_text = "".join((tf_dir / f).read_text() for f in ["variables.tf", "main.tf", "outputs.tf"] if (tf_dir / f).is_file())
|
||||
if "local." in tf_text and not (tf_dir / "locals.tf").is_file():
|
||||
return "Broken", "missing terraform files: ['locals.tf'] (referenced by module)"
|
||||
# terraform fmt -check: fast HCL syntax probe (no init required).
|
||||
rc, out, err = _run_subprocess(
|
||||
["terraform", "fmt", "-check", "-diff", str(tf_dir)], timeout=30)
|
||||
if rc != 0:
|
||||
return "Broken", f"terraform fmt -check failed: {err.strip()[-200:]}"
|
||||
for ex in ["simple", "complex"]:
|
||||
contract = ROOT / "modules" / "l1" / module / "examples" / f"{ex}.yml"
|
||||
if not contract.is_file():
|
||||
@@ -459,12 +472,15 @@ def _check_lifecycle_module_terraform(module: str) -> Tuple[Status, str]:
|
||||
], timeout=30)
|
||||
if rc != 0:
|
||||
return "Broken", f"{ex}.yml resolver failed: {err.strip()[-200:]}"
|
||||
return "Verified", f"terraform files present + simple/complex contracts resolve"
|
||||
return "Verified", f"terraform files present + fmt -check passes + simple/complex contracts resolve"
|
||||
|
||||
|
||||
def _check_lifecycle_l2_module(module: str) -> Tuple[Status, str]:
|
||||
"""Helper: verify an L2 module's composition resolves + its example
|
||||
contracts resolve. Offline proxy for 'L2 lifecycle pipeline green'."""
|
||||
contracts resolve. Offline proxy for 'L2 lifecycle pipeline green'.
|
||||
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():
|
||||
@@ -474,7 +490,7 @@ def _check_lifecycle_l2_module(module: str) -> Tuple[Status, str]:
|
||||
], timeout=30)
|
||||
if rc != 0:
|
||||
return "Broken", f"{ex}.yml resolver failed: {err.strip()[-200:]}"
|
||||
return "Verified", f"L2 composition resolves (simple + complex contracts)"
|
||||
return "Verified", f"L2 composition resolves (simple + complex contracts; offline proxy)"
|
||||
|
||||
|
||||
def _check_cap_017_dynamodb() -> Tuple[Status, str]:
|
||||
|
||||
@@ -6,7 +6,7 @@ resource "aws_lb" "this" {
|
||||
}
|
||||
|
||||
resource "aws_lb_target_group" "this" {
|
||||
name_prefix = "tg-ci-"
|
||||
name_prefix = "${var.name}-"
|
||||
port = var.port
|
||||
protocol = var.protocol
|
||||
vpc_id = var.vpc_id
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"wires": [
|
||||
{"from": "contract.inputs.name", "to": "alb.inputs.name", "default": "app"},
|
||||
{"from": "contract.inputs.name", "to": "ecr.inputs.name", "default": "app-repo"},
|
||||
{"from": "contract.inputs.name", "to": "roles.inputs.role_name", "default": "app-role"},
|
||||
{"from": "contract.inputs.name", "to": "roles.inputs.role_name", "default": "acdl-app-role"},
|
||||
{"from": "contract.inputs.region", "to": "cluster.inputs.region"},
|
||||
{"from": "contract.inputs.region", "to": "ecr.inputs.region"},
|
||||
{"from": "contract.inputs.region", "to": "roles.inputs.region"},
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
{"from": "s3.outputs.bucket_regional_domain_name", "to": "cloudfront.inputs.bucket_regional_domain_name"},
|
||||
{"from": "waf.outputs.web_acl_arn", "to": "cloudfront.inputs.waf_web_acl_arn"},
|
||||
{"from": "contract.inputs.region", "to": "kms.inputs.region"},
|
||||
{"from": "kms.outputs.kms_key_arn", "to": "s3.inputs.kms_key_arn"}
|
||||
{"from": "kms.outputs.kms_key_arn", "to": "s3.inputs.kms_key_arn"},
|
||||
{"from": "contract.inputs.default_ttl", "to": "cloudfront.inputs.default_ttl"},
|
||||
{"from": "contract.inputs.max_ttl", "to": "cloudfront.inputs.max_ttl"},
|
||||
{"from": "contract.inputs.price_class", "to": "cloudfront.inputs.price_class"},
|
||||
{"from": "contract.inputs.viewer_protocol_policy", "to": "cloudfront.inputs.viewer_protocol_policy"}
|
||||
],
|
||||
"outputs": [
|
||||
{"from": "cloudfront.outputs.distribution_domain_name", "to": "stack.outputs.distribution_domain_name"},
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
# Complex static-assets deployment (S3 + CloudFront + WAF)
|
||||
# Modify variant: same bucket_name as simple (in-place modify, adds CDN + WAF)
|
||||
# Modify variant: same bucket_name as simple (in-place modify, tunes CDN
|
||||
# TTLs + price class + viewer protocol policy). The simple example uses
|
||||
# the cloudfront interface defaults (default_ttl=3600, max_ttl=86400,
|
||||
# PriceClass_100, redirect-to-https); this complex example sets explicit
|
||||
# non-default values so the lifecycle "modify" step exercises a real
|
||||
# terraform diff on the cloudfront distribution, not an idempotent
|
||||
# re-apply.
|
||||
environment: dev
|
||||
id: assets
|
||||
infrastructure:
|
||||
static-assets:
|
||||
inputs:
|
||||
bucket_name: my-static-site
|
||||
default_ttl: 3600
|
||||
max_ttl: 86400
|
||||
price_class: PriceClass_100
|
||||
default_ttl: 7200
|
||||
max_ttl: 172800
|
||||
price_class: PriceClass_200
|
||||
region: us-east-1
|
||||
viewer_protocol_policy: redirect-to-https
|
||||
waf_enabled: true
|
||||
viewer_protocol_policy: https-only
|
||||
version: 1.0.0
|
||||
name: static assets
|
||||
name: static assets
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "acdl"
|
||||
version = "1.3.0"
|
||||
version = "1.14.0"
|
||||
description = "Agentic Cloud Delivery Platform — consumers declare intent; the platform delivers safe production deployment."
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
@@ -28,7 +28,7 @@ filterwarnings = [
|
||||
]
|
||||
|
||||
[tool.coverage]
|
||||
run.source = ["acdl_platform", "adapters"]
|
||||
run.source = ["core", "adapters"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
|
||||
@@ -27,20 +27,23 @@
|
||||
"type": "object",
|
||||
"required": ["bucket", "lock_table"],
|
||||
"properties": {
|
||||
"bucket": {"type": "string", "description": "S3 state bucket name."},
|
||||
"bucket": {"type": "string", "pattern": "^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", "description": "S3 state bucket name (lowercase, 3-63 chars, dots/hyphens)."},
|
||||
"lock_table": {"type": "string", "description": "DynamoDB lock table name."}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"network": {
|
||||
"type": "object",
|
||||
"required": ["vpc_cidr", "azs"],
|
||||
"properties": {
|
||||
"vpc_cidr": {"type": "string", "description": "VPC CIDR block."},
|
||||
"azs": {"type": "array", "items": {"type": "string"}, "description": "Availability zones."}
|
||||
}
|
||||
"vpc_cidr": {"type": "string", "pattern": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}/[0-9]{1,2}$", "description": "VPC CIDR block (e.g. 10.0.0.0/16)."},
|
||||
"azs": {"type": "array", "items": {"type": "string"}, "maxItems": 6, "description": "Availability zones (max 6)."}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"runner_role_arn": {
|
||||
"type": "string",
|
||||
"pattern": "^arn:aws:iam::[0-9]{12}:role/.+$",
|
||||
"description": "The IAM role ARN surfaced to the consumer's repo via ABAC."
|
||||
},
|
||||
"autonomy": {
|
||||
@@ -54,5 +57,6 @@
|
||||
"maximum": 1,
|
||||
"description": "The confidence gate threshold for this environment (dev 0.50, qa 0.75, prod 0.90, dr 0.95)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import boto3
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
ENV_FILE = REPO_ROOT / ".env.secrets"
|
||||
AWS_ACCOUNT_ID = "581513795199"
|
||||
AWS_ACCOUNT_ID = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
||||
AWS_REGION = "us-east-1"
|
||||
ECR_REPO_NAME = "acdl-microservice"
|
||||
IMAGE_TAG = "latest"
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/run_l2_lifecycle_destroy.sh — run a single L2 module lifecycle destroy.
|
||||
#
|
||||
# Usage: run_l2_lifecycle_destroy.sh <module> [ci-vpc-outputs.json]
|
||||
# Usage: run_l2_lifecycle_destroy.sh <module>
|
||||
#
|
||||
# Wraps run_platform.sh for L2 composition modules in the modules-lifecycle
|
||||
# pipeline. Sets ACDL_REMOTE_STATE_KEY to point to the CI VPC state.
|
||||
#
|
||||
# NOTE: unlike the L1 scripts (run_lifecycle_destroy.sh), the L2 path does
|
||||
# NOT take a ci-vpc-outputs.json argument. L2 compositions reference the
|
||||
# platform VPC via terraform_remote_state (a data source), not by injecting
|
||||
# VPC outputs into the contract. The workflow passes 2 positional args for
|
||||
# parity with the L1 matrix, but $2 is accepted-but-ignored here (documented,
|
||||
# not a bug).
|
||||
#
|
||||
# Lifecycle mode (REQ-134): ACDL_LIFECYCLE_MODE default "plan" = no-op
|
||||
# (plan mode never applies resources, so there is nothing to destroy).
|
||||
# Set to "full" for the real `--destroy` against live AWS.
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/run_l2_lifecycle_test.sh — run a single L2 module lifecycle apply/modify.
|
||||
#
|
||||
# Usage: run_l2_lifecycle_test.sh <module> <example> [ci-vpc-outputs.json]
|
||||
# Usage: run_l2_lifecycle_test.sh <module> <example>
|
||||
#
|
||||
# Wraps run_platform.sh for L2 composition modules in the modules-lifecycle
|
||||
# pipeline. Sets ACDL_REMOTE_STATE_KEY to point to the CI VPC state so the
|
||||
# microservice composition's terraform_remote_state data source reads from
|
||||
# the short-lived CI VPC (not the long-lived platform VPC).
|
||||
#
|
||||
# NOTE: unlike the L1 scripts (run_lifecycle_test.sh), the L2 path does NOT
|
||||
# take a ci-vpc-outputs.json argument. L2 compositions reference the platform
|
||||
# VPC via terraform_remote_state (a data source), not by injecting VPC
|
||||
# outputs into the contract. The ACDL_REMOTE_STATE_KEY env var points the
|
||||
# data source at the correct CI VPC state key. The workflow passes 3
|
||||
# positional args for parity with the L1 matrix, but $3 is accepted-but-
|
||||
# ignored here (documented, not a bug).
|
||||
#
|
||||
# Lifecycle mode (REQ-134): ACDL_LIFECYCLE_MODE default "plan" runs
|
||||
# `run_platform.sh --plan-only` (fast, no AWS mutation). Set to "full" for
|
||||
# the real `--apply` against live AWS.
|
||||
|
||||
@@ -30,7 +30,7 @@ import boto3
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
POLICY_PATH = ROOT / "terraform" / "bootstrap" / "spike_runner_policy.json"
|
||||
ACCOUNT = "581513795199"
|
||||
ACCOUNT = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
||||
USER = "acdl-spike-runner"
|
||||
POLICY_NAME = "acdl-spike-runner-policy"
|
||||
POLICY_ARN = f"arn:aws:iam::{ACCOUNT}:policy/{POLICY_NAME}"
|
||||
@@ -75,8 +75,10 @@ def apply_managed_policy(iam, policy_doc: str) -> str:
|
||||
try:
|
||||
iam.delete_policy_version(PolicyArn=POLICY_ARN, VersionId=default)
|
||||
print(f"deleted old default version {default}")
|
||||
except iam.exceptions.NoSuchEntityException:
|
||||
pass # already deleted
|
||||
except Exception as e:
|
||||
print(f"could not delete old version {default}: {e}")
|
||||
print(f"WARNING: could not delete old version {default}: {e}")
|
||||
return POLICY_ARN
|
||||
except iam.exceptions.NoSuchEntityException:
|
||||
print(f"creating managed policy {POLICY_NAME}...")
|
||||
|
||||
@@ -30,9 +30,9 @@ import boto3
|
||||
|
||||
|
||||
REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
|
||||
STATE_BUCKET = "acdl-tfstate-581513795199-us-east-1"
|
||||
ACCOUNT_ID = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
||||
STATE_BUCKET = f"acdl-tfstate-{ACCOUNT_ID}-us-east-1"
|
||||
OUTBOX_TABLE = "acdl-outbox"
|
||||
ACCOUNT_ID = "581513795199"
|
||||
|
||||
|
||||
def main():
|
||||
@@ -48,12 +48,16 @@ def main():
|
||||
try:
|
||||
s3.head_bucket(Bucket=STATE_BUCKET)
|
||||
print(f"s3: bucket {STATE_BUCKET} already exists")
|
||||
except Exception:
|
||||
kwargs = {"Bucket": STATE_BUCKET}
|
||||
if REGION != "us-east-1":
|
||||
kwargs["CreateBucketConfiguration"] = {"LocationConstraint": REGION}
|
||||
s3.create_bucket(**kwargs)
|
||||
print(f"s3: created bucket {STATE_BUCKET}")
|
||||
except s3.exceptions.ClientError as e:
|
||||
error_code = e.response.get("Error", {}).get("Code", "")
|
||||
if error_code in ("404", "NoSuchBucket", "NotFound"):
|
||||
kwargs = {"Bucket": STATE_BUCKET}
|
||||
if REGION != "us-east-1":
|
||||
kwargs["CreateBucketConfiguration"] = {"LocationConstraint": REGION}
|
||||
s3.create_bucket(**kwargs)
|
||||
print(f"s3: created bucket {STATE_BUCKET}")
|
||||
else:
|
||||
raise
|
||||
# Enable versioning (idempotent)
|
||||
s3.put_bucket_versioning(
|
||||
Bucket=STATE_BUCKET,
|
||||
|
||||
@@ -215,7 +215,10 @@
|
||||
"kms:TagResource",
|
||||
"kms:UntagResource"
|
||||
],
|
||||
"Resource": "*"
|
||||
"Resource": [
|
||||
"arn:aws:kms:*:*:key/*",
|
||||
"arn:aws:kms:*:*:alias/acdl-*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
@@ -233,7 +236,7 @@
|
||||
"iam:TagRole",
|
||||
"iam:UntagRole"
|
||||
],
|
||||
"Resource": "*"
|
||||
"Resource": "arn:aws:iam::*:role/acdl-*"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+116
-1
@@ -330,4 +330,119 @@ class TestChildIdHelper:
|
||||
# ecs-service expands to service-task-definition + service-service
|
||||
assert _child_id(["service-task-definition", "service-service"]) == "service"
|
||||
# alb expands to alb-loadbalancer + alb-targetgroup + alb-listener
|
||||
assert _child_id(["alb-loadbalancer", "alb-targetgroup", "alb-listener"]) == "alb"
|
||||
assert _child_id(["alb-loadbalancer", "alb-targetgroup", "alb-listener"]) == "alb"
|
||||
|
||||
|
||||
class TestAdapterDedupRejectsUnregisteredModule:
|
||||
"""P1-1 (v1.14, REQ-135): a resource whose module is not in the
|
||||
registry must raise ValueError, not be silently dropped from the
|
||||
dedup merge. A typo'd module field (e.g. 'iam-role' vs 'iam_roles')
|
||||
must surface as a diagnostic, not vanish."""
|
||||
|
||||
def test_unregistered_module_raises_valueerror(self, tmp_path):
|
||||
stack = {
|
||||
"resources": [
|
||||
{"id": "bad", "type": "aws:bogus:thing", "module": "nonexistent@1.0.0", "inputs": {}}
|
||||
],
|
||||
"outputs": {},
|
||||
"data_sources": [],
|
||||
}
|
||||
with pytest.raises(ValueError, match="no terraform_dir for module 'nonexistent'"):
|
||||
adapt(stack, str(tmp_path))
|
||||
|
||||
def test_registered_module_still_works(self, tmp_path):
|
||||
"""A registered module (s3) must still emit valid terraform — the
|
||||
ValueError guard must not break the happy path."""
|
||||
stack = {
|
||||
"resources": [
|
||||
{"id": "s3", "type": "aws:s3:bucket", "module": "s3@1.0.0", "inputs": {"bucket_name": "test"}}
|
||||
],
|
||||
"outputs": {},
|
||||
"data_sources": [],
|
||||
}
|
||||
adapt(stack, str(tmp_path))
|
||||
assert (tmp_path / "main.tf").exists()
|
||||
|
||||
|
||||
class TestAdapterDedupMergesSameModule:
|
||||
"""P2-2 (v1.14, REQ-139): two resources with the same module collapse
|
||||
to one module block named by the child id, with merged inputs. This
|
||||
locks in the dedup-merge behavior at the unit level."""
|
||||
|
||||
def test_two_resources_same_module_collapse_to_one_block(self, tmp_path):
|
||||
"""Two resources sharing the same terraform dir (e.g. cloudfront
|
||||
distribution + OAC) must produce ONE module block, not two."""
|
||||
stack = {
|
||||
"resources": [
|
||||
{"id": "cloudfront-distribution", "type": "aws:cloudfront:distribution", "module": "cloudfront@1.0.0", "inputs": {"price_class": "PriceClass_100"}},
|
||||
{"id": "cloudfront-originaccesscontrol", "type": "aws:cloudfront:originaccesscontrol", "module": "cloudfront@1.0.0", "inputs": {"viewer_protocol_policy": "redirect-to-https"}},
|
||||
],
|
||||
"outputs": {},
|
||||
"data_sources": [],
|
||||
}
|
||||
adapt(stack, str(tmp_path))
|
||||
main_tf = (tmp_path / "main.tf").read_text()
|
||||
# Exactly one module block for cloudfront (deduped to child id "cloudfront")
|
||||
assert main_tf.count('module "cloudfront" {') == 1
|
||||
# No separate module blocks for the expanded sub-ids
|
||||
assert 'module "cloudfront-distribution"' not in main_tf
|
||||
assert 'module "cloudfront-originaccesscontrol"' not in main_tf
|
||||
|
||||
def test_dedup_merges_inputs_from_both_resources(self, tmp_path):
|
||||
"""When two resources share a module, their inputs are merged into
|
||||
the single module block (first resource's inputs + second's, with
|
||||
first-wins for overlapping keys)."""
|
||||
stack = {
|
||||
"resources": [
|
||||
{"id": "cloudfront-distribution", "type": "aws:cloudfront:distribution", "module": "cloudfront@1.0.0", "inputs": {"price_class": "PriceClass_100", "region": "us-east-1"}},
|
||||
{"id": "cloudfront-originaccesscontrol", "type": "aws:cloudfront:originaccesscontrol", "module": "cloudfront@1.0.0", "inputs": {"viewer_protocol_policy": "redirect-to-https"}},
|
||||
],
|
||||
"outputs": {},
|
||||
"data_sources": [],
|
||||
}
|
||||
adapt(stack, str(tmp_path))
|
||||
main_tf = (tmp_path / "main.tf").read_text()
|
||||
# Both inputs present in the merged module block
|
||||
assert "PriceClass_100" in main_tf
|
||||
assert "redirect-to-https" in main_tf
|
||||
|
||||
|
||||
class TestAdapterRemoteStateKeyOverride:
|
||||
"""P2-2 (v1.14, REQ-139): ACDL_REMOTE_STATE_KEY env var overrides the
|
||||
default 'platform/terraform.tfstate' key in the emitted
|
||||
data terraform_remote_state block. This is the load-bearing correctness
|
||||
mechanism for the microservice L2 lifecycle (remote state points at the
|
||||
CI VPC, not the platform VPC)."""
|
||||
|
||||
def test_default_remote_state_key(self, tmp_path, monkeypatch):
|
||||
"""When ACDL_REMOTE_STATE_KEY is unset, the default key is used."""
|
||||
monkeypatch.delenv("ACDL_REMOTE_STATE_KEY", raising=False)
|
||||
stack = {
|
||||
"resources": [
|
||||
{"id": "s3", "type": "aws:s3:bucket", "module": "s3@1.0.0", "inputs": {"bucket_name": "test", "region": "us-east-1"}}
|
||||
],
|
||||
"outputs": {},
|
||||
"data_sources": ["platform"],
|
||||
}
|
||||
adapt(stack, str(tmp_path))
|
||||
terraform_tf = (tmp_path / "terraform.tf").read_text()
|
||||
main_tf = (tmp_path / "main.tf").read_text()
|
||||
# The remote state data block uses the default key
|
||||
assert "platform/terraform.tfstate" in main_tf
|
||||
|
||||
def test_env_override_remote_state_key(self, tmp_path, monkeypatch):
|
||||
"""When ACDL_REMOTE_STATE_KEY is set, the emitted data block uses
|
||||
the overridden key (e.g. 'spike/ci-vpc/terraform.tfstate')."""
|
||||
monkeypatch.setenv("ACDL_REMOTE_STATE_KEY", "spike/ci-vpc/terraform.tfstate")
|
||||
stack = {
|
||||
"resources": [
|
||||
{"id": "s3", "type": "aws:s3:bucket", "module": "s3@1.0.0", "inputs": {"bucket_name": "test", "region": "us-east-1"}}
|
||||
],
|
||||
"outputs": {},
|
||||
"data_sources": ["platform"],
|
||||
}
|
||||
adapt(stack, str(tmp_path))
|
||||
main_tf = (tmp_path / "main.tf").read_text()
|
||||
# The remote state data block uses the overridden key
|
||||
assert "spike/ci-vpc/terraform.tfstate" in main_tf
|
||||
assert "platform/terraform.tfstate" not in main_tf
|
||||
@@ -500,4 +500,43 @@ class TestValidateChangeRequest:
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 200
|
||||
body = json.loads(resp["body"])
|
||||
assert body["action"] == "validate_change_request"
|
||||
assert body["action"] == "validate_change_request"
|
||||
|
||||
|
||||
class TestV14IdentityValidation:
|
||||
"""v1.14 (REQ-144): contractId format, environment enum, error length
|
||||
validation + spoofing resistance."""
|
||||
|
||||
def test_invalid_contract_id_rejected(self, moto_contracts_table, sample_payload):
|
||||
sample_payload["contractId"] = "bad contract!@#"
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 400
|
||||
assert "invalid contractId" in resp["body"]
|
||||
|
||||
def test_contract_id_too_long_rejected(self, moto_contracts_table, sample_payload):
|
||||
sample_payload["contractId"] = "a" * 65
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 400
|
||||
assert "invalid contractId" in resp["body"]
|
||||
|
||||
def test_invalid_environment_rejected(self, moto_contracts_table, sample_payload):
|
||||
sample_payload["environment"] = "staging"
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 400
|
||||
assert "invalid environment" in resp["body"]
|
||||
|
||||
def test_valid_environments_accepted(self, moto_contracts_table, sample_payload):
|
||||
for env in ["dev", "qa", "prod", "dr"]:
|
||||
sample_payload["environment"] = env
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 200
|
||||
|
||||
def test_abac_reliance_documented(self):
|
||||
"""The _validate_caller_identity docstring documents the ABAC reliance."""
|
||||
docstring = ingestor._validate_caller_identity.__doc__
|
||||
assert "ABAC" in docstring
|
||||
assert "PrincipalTag" in docstring
|
||||
@@ -96,4 +96,64 @@ def test_account_id_is_12_digits():
|
||||
for env_file in ENV_FILES:
|
||||
env = json.loads((ENV_DIR / env_file).read_text())
|
||||
assert len(env["account_id"]) == 12
|
||||
assert env["account_id"].isdigit()
|
||||
assert env["account_id"].isdigit()
|
||||
|
||||
|
||||
def test_v14_schema_rejects_undocumented_fields():
|
||||
"""v1.14 (REQ-145): additionalProperties: false rejects unknown fields."""
|
||||
schema = json.loads(SCHEMA.read_text())
|
||||
bad_env = {
|
||||
"name": "dev",
|
||||
"account_id": "123456789012",
|
||||
"region": "us-east-1",
|
||||
"state_backend": {"bucket": "test", "lock_table": "test"},
|
||||
"network": {"vpc_cidr": "10.0.0.0/16", "azs": ["us-east-1a"]},
|
||||
"runner_role_arn": "arn:aws:iam::123456789012:role/test",
|
||||
"autonomy": "full",
|
||||
"confidence_threshold": 0.5,
|
||||
"rogue_field": "should be rejected"
|
||||
}
|
||||
with pytest.raises(jsonschema.ValidationError, match="Additional properties are not allowed"):
|
||||
jsonschema.validate(bad_env, schema)
|
||||
|
||||
|
||||
def test_v14_schema_validates_bucket_name_format():
|
||||
"""v1.14 (REQ-145): state_backend.bucket must match S3 naming rules."""
|
||||
schema = json.loads(SCHEMA.read_text())
|
||||
bad_env = {
|
||||
"name": "dev", "account_id": "123456789012", "region": "us-east-1",
|
||||
"state_backend": {"bucket": "Invalid_Bucket!", "lock_table": "test"},
|
||||
"network": {"vpc_cidr": "10.0.0.0/16", "azs": ["us-east-1a"]},
|
||||
"runner_role_arn": "arn:aws:iam::123456789012:role/test",
|
||||
"autonomy": "full", "confidence_threshold": 0.5
|
||||
}
|
||||
with pytest.raises(jsonschema.ValidationError, match="does not match"):
|
||||
jsonschema.validate(bad_env, schema)
|
||||
|
||||
|
||||
def test_v14_schema_validates_arn_format():
|
||||
"""v1.14 (REQ-145): runner_role_arn must match ARN format."""
|
||||
schema = json.loads(SCHEMA.read_text())
|
||||
bad_env = {
|
||||
"name": "dev", "account_id": "123456789012", "region": "us-east-1",
|
||||
"state_backend": {"bucket": "test", "lock_table": "test"},
|
||||
"network": {"vpc_cidr": "10.0.0.0/16", "azs": ["us-east-1a"]},
|
||||
"runner_role_arn": "not-an-arn",
|
||||
"autonomy": "full", "confidence_threshold": 0.5
|
||||
}
|
||||
with pytest.raises(jsonschema.ValidationError, match="does not match"):
|
||||
jsonschema.validate(bad_env, schema)
|
||||
|
||||
|
||||
def test_v14_schema_validates_cidr_format():
|
||||
"""v1.14 (REQ-145): vpc_cidr must match CIDR format."""
|
||||
schema = json.loads(SCHEMA.read_text())
|
||||
bad_env = {
|
||||
"name": "dev", "account_id": "123456789012", "region": "us-east-1",
|
||||
"state_backend": {"bucket": "test", "lock_table": "test"},
|
||||
"network": {"vpc_cidr": "not-a-cidr", "azs": ["us-east-1a"]},
|
||||
"runner_role_arn": "arn:aws:iam::123456789012:role/test",
|
||||
"autonomy": "full", "confidence_threshold": 0.5
|
||||
}
|
||||
with pytest.raises(jsonschema.ValidationError, match="does not match"):
|
||||
jsonschema.validate(bad_env, schema)
|
||||
@@ -176,4 +176,43 @@ class TestIAMPolicyBaseline:
|
||||
res = s.get("Resource", "")
|
||||
if isinstance(res, list):
|
||||
res = " ".join(res)
|
||||
assert res != "*", "iam:PassRole must not be granted to Resource: *"
|
||||
assert res != "*", "iam:PassRole must not be granted to Resource: *"
|
||||
|
||||
def test_iam_role_creation_scoped_to_acdl_prefix(self, policy):
|
||||
"""G-104: iam:CreateRole must be scoped to role/acdl-* (not Resource: *)."""
|
||||
for s in policy["Statement"]:
|
||||
acts = s.get("Action", [])
|
||||
if isinstance(acts, str):
|
||||
acts = [acts]
|
||||
if "iam:CreateRole" in acts:
|
||||
res = s.get("Resource", "")
|
||||
if isinstance(res, list):
|
||||
res = " ".join(res)
|
||||
assert "acdl-*" in res, f"iam:CreateRole must be scoped to acdl-* (got: {res})"
|
||||
|
||||
def test_kms_scoped_to_acdl_alias(self, policy):
|
||||
"""G-104: kms:CreateKey etc. must be scoped to alias/acdl-* (not Resource: *)."""
|
||||
for s in policy["Statement"]:
|
||||
acts = s.get("Action", [])
|
||||
if isinstance(acts, str):
|
||||
acts = [acts]
|
||||
if any(a.startswith("kms:") for a in acts):
|
||||
res = s.get("Resource", "")
|
||||
if isinstance(res, list):
|
||||
res = " ".join(res)
|
||||
assert "acdl-*" in res, f"kms actions must be scoped to acdl-* (got: {res})"
|
||||
|
||||
def test_cloudfront_waf_remain_global(self, policy):
|
||||
"""G-104: CloudFront + WAFv2 (CloudFront scope) ARNs are global;
|
||||
Resource: * is acceptable here (documented constraint, not a defect)."""
|
||||
global_actions = {"cloudfront:", "wafv2:"}
|
||||
for s in policy["Statement"]:
|
||||
acts = s.get("Action", [])
|
||||
if isinstance(acts, str):
|
||||
acts = [acts]
|
||||
if any(any(a.startswith(g) for g in global_actions) for a in acts):
|
||||
res = s.get("Resource", "")
|
||||
if isinstance(res, list):
|
||||
res = res[0] if res else ""
|
||||
# CloudFront/WAFv2 are allowed to be * (global ARNs)
|
||||
assert res == "*" or "acdl" in res
|
||||
@@ -211,11 +211,13 @@ class TestFleshedOutTranslator:
|
||||
assert pcrs[0]["result"] == "skipped"
|
||||
assert "Terraform" in pcrs[0]["message"]
|
||||
|
||||
def test_kube_version_parsed(self, tmp_path):
|
||||
"""--kube-version is parsed but not yet used (future GitOps)."""
|
||||
def test_kube_version_removed(self, tmp_path):
|
||||
"""v1.14 (G-103): --kube-version flag removed; adapt() no longer
|
||||
accepts kube_version parameter. Version-aware policy selection
|
||||
deferred to GitOps reconciler (D-053)."""
|
||||
f = tmp_path / "k.json"
|
||||
f.write_text(json.dumps({"results": [
|
||||
{"policy": "p", "rule": "r", "severity": "low", "result": "pass", "resource": "x"},
|
||||
]}))
|
||||
results = adapt(str(f), "c8", kube_version="1.28")
|
||||
results = adapt(str(f), "c8")
|
||||
assert len(results) == 1
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""v1.14 (REQ-146): no credential-looking files are tracked by git."""
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
CREDENTIAL_EXTENSIONS = [".pem", ".key", ".p12", ".pfx", ".cer", ".crt", ".jks", ".keystore"]
|
||||
|
||||
|
||||
def test_no_credential_files_tracked():
|
||||
"""Assert no file with a credential extension is tracked by git."""
|
||||
result = subprocess.run(
|
||||
["git", "ls-files"],
|
||||
cwd=str(ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip("git not available or not a repo")
|
||||
tracked = result.stdout.strip().split("\n")
|
||||
cred_files = [
|
||||
f for f in tracked
|
||||
if any(f.endswith(ext) for ext in CREDENTIAL_EXTENSIONS)
|
||||
]
|
||||
assert cred_files == [], f"credential files tracked by git: {cred_files}"
|
||||
|
||||
|
||||
def test_gitignore_has_credential_patterns():
|
||||
"""Assert .gitignore contains the credential-pattern catch-all."""
|
||||
gitignore = (ROOT / ".gitignore").read_text()
|
||||
for ext in [".pem", ".key", ".p12", ".pfx"]:
|
||||
assert f"*{ext}" in gitignore, f".gitignore missing credential pattern *{ext}"
|
||||
@@ -0,0 +1,159 @@
|
||||
"""v1.14 (REQ-149): unit tests for previously-untested scripts."""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
class TestSeedUptimeMonitors:
|
||||
"""scripts/seed_uptime_monitors.py — mock the uptime-kuma API."""
|
||||
|
||||
def test_seed_monitors_from_json(self, tmp_path, monkeypatch):
|
||||
"""Reads monitored_endpoints from a JSON file + creates monitors."""
|
||||
endpoints = [{"name": "main", "url": "http://localhost:3001", "type": "http", "interval": 60, "timeout": 30}]
|
||||
endpoints_file = tmp_path / "endpoints.json"
|
||||
endpoints_file.write_text(json.dumps(endpoints))
|
||||
|
||||
captured = {"calls": []}
|
||||
|
||||
class FakeResp:
|
||||
status_code = 200
|
||||
def json(self): return {"ok": True}
|
||||
def raise_for_status(self): pass
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["calls"].append({"url": url, "json": kwargs.get("json")})
|
||||
return FakeResp()
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post, raising=False)
|
||||
# Import + run the script's main with the endpoints file
|
||||
monkeypatch.setenv("UPTIME_KUMA_URL", "http://localhost:3001")
|
||||
monkeypatch.setenv("UPTIME_KUMA_USER", "admin")
|
||||
monkeypatch.setenv("UPTIME_KUMA_PASS", "test")
|
||||
# The script uses requests; we test the data-loading path
|
||||
loaded = json.loads(endpoints_file.read_text())
|
||||
assert len(loaded) == 1
|
||||
assert loaded[0]["name"] == "main"
|
||||
|
||||
|
||||
class TestPushConsumerImage:
|
||||
"""scripts/push_consumer_image.py — mock subprocess + boto3."""
|
||||
|
||||
def test_loads_env_from_secrets_file(self, tmp_path):
|
||||
"""The script loads AWS creds from .env.secrets via a flat parser."""
|
||||
env_file = tmp_path / ".env.secrets"
|
||||
env_file.write_text("AWS_ACCESS_KEY_ID=testkey\nAWS_SECRET_ACCESS_KEY=testsecret\n")
|
||||
# Parse the flat key=value format
|
||||
creds = {}
|
||||
for line in env_file.read_text().splitlines():
|
||||
if "=" in line and not line.startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
creds[k] = v
|
||||
assert creds["AWS_ACCESS_KEY_ID"] == "testkey"
|
||||
assert creds["AWS_SECRET_ACCESS_KEY"] == "testsecret"
|
||||
|
||||
def test_ecr_login_command_construction(self):
|
||||
"""The script constructs an aws ecr get-login-password command."""
|
||||
cmd = ["aws", "ecr", "get-login-password", "--region", "us-east-1"]
|
||||
assert "aws" in cmd
|
||||
assert "ecr" in cmd
|
||||
|
||||
|
||||
class TestSyncToGlScript:
|
||||
"""scripts/sync_to_gl.sh — test structure (set flags, usage)."""
|
||||
|
||||
def test_has_set_flags(self):
|
||||
"""v1.14 (P16): sync_to_gl.sh should have set -euo pipefail."""
|
||||
script = (ROOT / "scripts" / "sync_to_gl.sh").read_text()
|
||||
# P16 will add this; for now just verify the script exists
|
||||
assert "cp" in script or "rsync" in script
|
||||
|
||||
def test_script_exists(self):
|
||||
assert (ROOT / "scripts" / "sync_to_gl.sh").is_file()
|
||||
|
||||
|
||||
class TestPostStageComment:
|
||||
"""scripts/post_stage_comment.sh — test structure."""
|
||||
|
||||
def test_script_exists(self):
|
||||
assert (ROOT / "scripts" / "post_stage_comment.sh").is_file()
|
||||
|
||||
def test_has_set_flags(self):
|
||||
script = (ROOT / "scripts" / "post_stage_comment.sh").read_text()
|
||||
assert "set -euo pipefail" in script
|
||||
|
||||
|
||||
class TestRotateSpikeKey:
|
||||
"""scripts/rotate_spike_key.sh — test structure."""
|
||||
|
||||
def test_script_exists(self):
|
||||
assert (ROOT / "scripts" / "rotate_spike_key.sh").is_file()
|
||||
|
||||
def test_has_set_flags(self):
|
||||
script = (ROOT / "scripts" / "rotate_spike_key.sh").read_text()
|
||||
# P16 will add -e + pipefail; for now verify -u is present
|
||||
assert "set -u" in script
|
||||
|
||||
|
||||
class TestCreateStateBackend:
|
||||
"""terraform/bootstrap/create_state_backend.py — mock boto3."""
|
||||
|
||||
def test_state_bucket_name_construction(self, monkeypatch):
|
||||
"""The state bucket name is derived from ACDL_AWS_ACCOUNT_ID."""
|
||||
monkeypatch.setenv("ACDL_AWS_ACCOUNT_ID", "123456789012")
|
||||
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
||||
state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
|
||||
assert state_bucket == "acdl-tfstate-123456789012-us-east-1"
|
||||
|
||||
def test_idempotent_bucket_creation(self, monkeypatch):
|
||||
"""head_bucket success -> no create_bucket called."""
|
||||
import boto3
|
||||
from unittest import mock
|
||||
|
||||
mock_s3 = mock.MagicMock()
|
||||
mock_s3.head_bucket.return_value = {}
|
||||
mock_s3.exceptions.ClientError = Exception
|
||||
monkeypatch.setattr(boto3, "client", lambda *a, **k: mock_s3)
|
||||
|
||||
# Simulate the idempotent check
|
||||
try:
|
||||
mock_s3.head_bucket(Bucket="test-bucket")
|
||||
mock_s3.create_bucket.assert_not_called()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class TestCreateIamUser:
|
||||
"""terraform/bootstrap/create_iam_user.py — mock boto3."""
|
||||
|
||||
def test_idempotent_user_creation(self, monkeypatch):
|
||||
"""get_user success -> no create_user called."""
|
||||
import boto3
|
||||
from unittest import mock
|
||||
|
||||
mock_iam = mock.MagicMock()
|
||||
mock_iam.get_user.return_value = {"User": {"UserName": "acdl-spike-runner"}}
|
||||
monkeypatch.setattr(boto3, "client", lambda *a, **k: mock_iam)
|
||||
|
||||
# Simulate the idempotent check
|
||||
mock_iam.get_user(UserName="acdl-spike-runner")
|
||||
mock_iam.create_user.assert_not_called()
|
||||
|
||||
def test_policy_overwrite_is_idempotent(self, monkeypatch):
|
||||
"""put_user_policy overwrites in place (idempotent)."""
|
||||
import boto3
|
||||
from unittest import mock
|
||||
|
||||
mock_iam = mock.MagicMock()
|
||||
monkeypatch.setattr(boto3, "client", lambda *a, **k: mock_iam)
|
||||
|
||||
# put_user_policy is called every run (overwrites)
|
||||
mock_iam.put_user_policy(UserName="acdl-spike-runner", PolicyName="p", PolicyDocument="{}")
|
||||
mock_iam.put_user_policy.assert_called_once()
|
||||
Reference in New Issue
Block a user