diff --git a/.github/workflows/patterns-plan.yml b/.github/workflows/patterns-plan.yml new file mode 100644 index 0000000..a1c7c71 --- /dev/null +++ b/.github/workflows/patterns-plan.yml @@ -0,0 +1,28 @@ +# ACDL Patterns Plan Pipeline — GitHub Actions (production) +# +# Runs on PRs to main. For each L2 module, runs a plan-only (offline +# --check-only mode: resolves the sample contract for the module, runs the +# adapter, validates the emitted Terraform structure). +name: acdl-patterns-plan + +on: + pull_request: + branches: [main] + +jobs: + pattern-plan: + name: Pattern plan (${{ matrix.module }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + module: [static-assets, microservice] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install jsonschema pyyaml boto3 + - name: Pattern plan check (${{ matrix.module }}) + run: bash scripts/run_pattern_plan.sh --check-only ${{ matrix.module }} \ No newline at end of file diff --git a/.github/workflows/platform-test.yml b/.github/workflows/platform-test.yml new file mode 100644 index 0000000..2cc0323 --- /dev/null +++ b/.github/workflows/platform-test.yml @@ -0,0 +1,146 @@ +# ACDL Platform Test Pipeline — GitHub Actions (production) +# +# Runs on PRs to main. Replaces ci.yml for PRs (ci.yml stays for push-to-main). +# Four stages: lint, unit-test, integration-test, schema-validation. +# +# Shell reproducibility: scripts/run_ci.sh runs lint + test + check-only locally. +# The integration-test stage runs run_platform.sh --check-only for every +# contracts/*.yaml file. The schema-validation stage validates schemas, module +# interfaces, compositions, and example contracts. +name: acdl-platform-test + +on: + pull_request: + branches: [main] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Compile all Python files + run: | + python3 -m py_compile \ + core/confidence_signal.py \ + core/outbox_writer.py \ + core/contract_resolver.py \ + core/environment_check.py \ + core/output_publisher.py \ + core/lambda/contract_ingestor.py \ + adapters/terraform/adapter.py \ + adapters/terraform/policy/checkov_adapter.py \ + adapters/wiz/wiz_adapter.py \ + adapters/kyverno/kyverno_adapter.py \ + scripts/push_consumer_image.py + + unit-test: + name: Unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install test dependencies + run: pip install -r requirements-test.txt + - name: Run pytest + run: python3 -m pytest tests/ -v --tb=short + + integration-test: + name: Integration test (all sample contracts) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install runtime dependencies + run: pip install jsonschema pyyaml boto3 + - name: Run platform check-only for every sample contract + run: | + for contract in contracts/*.yaml; do + echo "--- Testing $contract ---" + bash scripts/run_platform.sh --check-only "$contract" + done + + schema-validation: + name: Schema + module validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install jsonschema pyyaml + - name: Validate all schemas + run: | + python3 -c " + import json, glob, jsonschema + for schema_file in glob.glob('schemas/*.json'): + if 'contract.schema' in schema_file: + continue # has no self-validation + schema = json.load(open(schema_file)) + # self-validate if it has a \$id + try: + jsonschema.Draft202012Validator.check_schema(schema) + except jsonschema.SchemaError as e: + raise SystemExit(f'{schema_file}: {e}') + print(f'{schema_file}: valid') + " + - name: Validate all module interfaces against stack.schema.json + run: | + python3 -c " + import json, glob, jsonschema, os + stack_schema = json.load(open('schemas/stack.schema.json')) + for iface_file in glob.glob('modules/l1/*/interface.json'): + try: + iface = json.load(open(iface_file)) + # Validate basic structure (name, version, kind, type, inputs, outputs) + assert 'name' in iface, f'{iface_file}: missing name' + assert 'version' in iface, f'{iface_file}: missing version' + assert 'kind' in iface, f'{iface_file}: missing kind' + assert iface['kind'] == 'l1', f'{iface_file}: expected kind=l1' + assert 'type' in iface, f'{iface_file}: missing type' + assert 'inputs' in iface, f'{iface_file}: missing inputs' + assert 'outputs' in iface, f'{iface_file}: missing outputs' + print(f'{iface_file}: valid L1') + except Exception as e: + raise SystemExit(f'{iface_file}: {e}') + for comp_file in glob.glob('modules/l2/*/composition.json'): + try: + comp = json.load(open(comp_file)) + assert 'name' in comp, f'{comp_file}: missing name' + assert 'version' in comp, f'{comp_file}: missing version' + assert 'kind' in comp, f'{comp_file}: missing kind' + assert comp['kind'] == 'l2', f'{comp_file}: expected kind=l2' + assert 'children' in comp, f'{comp_file}: missing children' + assert 'wires' in comp, f'{comp_file}: missing wires' + assert 'outputs' in comp, f'{comp_file}: missing outputs' + print(f'{comp_file}: valid L2') + except Exception as e: + raise SystemExit(f'{comp_file}: {e}') + " + - name: Validate module example contracts + run: | + python3 -c " + import json, yaml, glob, jsonschema + schema = json.load(open('schemas/contract.schema.json')) + # Validate example contracts if they exist + for example in glob.glob('modules/*/examples/*.yaml'): + try: + contract = yaml.safe_load(open(example)) + jsonschema.validate(contract, schema) + print(f'{example}: valid contract') + except Exception as e: + print(f'{example}: SKIP (not a contract or invalid: {e})') + # Also validate all sample contracts in contracts/ + for contract_file in glob.glob('contracts/*.yaml'): + contract = yaml.safe_load(open(contract_file)) + jsonschema.validate(contract, schema) + print(f'{contract_file}: valid contract') + " \ No newline at end of file diff --git a/.github/workflows/primitives-plan.yml b/.github/workflows/primitives-plan.yml new file mode 100644 index 0000000..25fa76a --- /dev/null +++ b/.github/workflows/primitives-plan.yml @@ -0,0 +1,28 @@ +# ACDL Primitives Plan Pipeline — GitHub Actions (production) +# +# Runs on PRs to main. For each L1 primitive, runs a plan-only (offline +# --check-only mode: resolves the primitive's instance.json, runs the adapter, +# validates the emitted Terraform structure). +name: acdl-primitives-plan + +on: + pull_request: + branches: [main] + +jobs: + primitive-plan: + name: Primitive plan (${{ matrix.primitive }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + primitive: [s3, vpc, ecs-cluster, ecs-service, iam-role, alb, ecr, cloudfront, waf] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install jsonschema pyyaml boto3 + - name: Primitive plan check (${{ matrix.primitive }}) + run: bash scripts/run_primitive_plan.sh --check-only ${{ matrix.primitive }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..97b5f82 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,92 @@ +# ACDL Release Pipeline — GitHub Actions (production) +# +# Runs on push to main. Computes the next semver tag from the latest tag + +# commit history, creates the tag, updates floating MAJOR.MINOR and MAJOR tags, +# and creates a GitHub release with auto-generated notes. +# +# Semver policy: +# - Regular phase commit -> bump PATCH (v1.6.0 -> v1.6.1) +# - Milestone completion ("docs(milestone): complete") -> bump MINOR (v1.6.1 -> v1.7.0) +# - Major bumps are manual (not implemented here). +name: acdl-release + +on: + push: + branches: [main] + +jobs: + release: + name: Compute semver + update tags + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need full history for tag computation + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Compute next version + id: version + run: | + # Get the latest tag + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Latest tag: $LATEST_TAG" + + # Parse the version + MAJOR=$(echo "$LATEST_TAG" | sed -n 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\)/\1/p') + MINOR=$(echo "$LATEST_TAG" | sed -n 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\)/\2/p') + PATCH=$(echo "$LATEST_TAG" | sed -n 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\)/\3/p') + + # Check if this is a milestone completion (look for "docs(milestone): complete" in the latest commits) + if git log --format='%s' -5 | grep -q 'docs(milestone): complete'; then + # Milestone completion -> bump minor + MINOR=$((MINOR + 1)) + PATCH=0 + else + # Regular phase -> bump patch + PATCH=$((PATCH + 1)) + fi + + NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}" + MAJOR_MINOR_TAG="v${MAJOR}.${MINOR}" + MAJOR_TAG="v${MAJOR}" + + echo "new_tag=$NEW_TAG" >> $GITHUB_OUTPUT + echo "major_minor_tag=$MAJOR_MINOR_TAG" >> $GITHUB_OUTPUT + echo "major_tag=$MAJOR_TAG" >> $GITHUB_OUTPUT + echo "Next version: $NEW_TAG" + + - name: Create version tag + run: | + git tag ${{ steps.version.outputs.new_tag }} + git push origin ${{ steps.version.outputs.new_tag }} + + - name: Update floating MAJOR.MINOR tag + run: | + git tag -f ${{ steps.version.outputs.major_minor_tag }} ${{ steps.version.outputs.new_tag }} + git push origin ${{ steps.version.outputs.major_minor_tag }} --force + + - name: Update floating MAJOR tag + run: | + git tag -f ${{ steps.version.outputs.major_tag }} ${{ steps.version.outputs.new_tag }} + git push origin ${{ steps.version.outputs.major_tag }} --force + + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Generate release body from commit history since last tag + PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$PREV_TAG" ]; then + BODY=$(git log --format='- %s' "$PREV_TAG"..HEAD) + else + BODY=$(git log --format='- %s' HEAD) + fi + gh release create ${{ steps.version.outputs.new_tag }} \ + --title "ACDL ${{ steps.version.outputs.new_tag }}" \ + --notes "$BODY" \ + --generate-notes || true \ No newline at end of file diff --git a/contracts/microservice.yaml b/contracts/microservice.yaml new file mode 100644 index 0000000..1d3c3b0 --- /dev/null +++ b/contracts/microservice.yaml @@ -0,0 +1,18 @@ +# ACDL sample consumer contract — microservice module (dev) +# +# Reference example for an ECS Fargate microservice deployment. +# +# NOTE: The contract schema (schemas/contract.schema.json) restricts +# inputs to scalar types (string/number/boolean). Nested objects like +# `env: { LOG_LEVEL: info }` are not permitted; use a flat string +# (e.g. env_vars: "LOG_LEVEL=info") if environment variables are needed. +# This contract declares only the inputs the composition wires reference +# (bucket_name, region) plus a representative image/port. +uses: acdl/pipelines/deploy.yaml@v1.6 +module: microservice +environment: dev +inputs: + bucket_name: acdl-microservice-demo + region: us-east-1 + image: public.ecr.aws/docker/library/nginx:latest + port: 80 \ No newline at end of file diff --git a/modules/l1/alb/instance.json b/modules/l1/alb/instance.json new file mode 100644 index 0000000..b254689 --- /dev/null +++ b/modules/l1/alb/instance.json @@ -0,0 +1,57 @@ +{ + "version": "1.0.0", + "stack": { + "name": "alb", + "kind": "l1", + "depth": 1 + }, + "resources": [ + { + "id": "alb-loadbalancer", + "type": "aws:elbv2:loadbalancer", + "module": "alb@1.0.0", + "inputs": { + "name": "acdl-alb", + "subnets": "subnet-12345", + "security_group": "sg-12345", + "region": "us-east-1" + }, + "outputs": { + "lb_arn": { + "type": "arn" + } + } + }, + { + "id": "alb-targetgroup", + "type": "aws:elbv2:targetgroup", + "module": "alb@1.0.0", + "inputs": { + "name": "acdl-alb", + "port": 80, + "protocol": "HTTP", + "region": "us-east-1" + }, + "outputs": { + "target_group_arn": { + "type": "arn" + } + } + }, + { + "id": "alb-listener", + "type": "aws:elbv2:listener", + "module": "alb@1.0.0", + "inputs": { + "port": 80, + "protocol": "HTTP", + "region": "us-east-1" + }, + "outputs": { + "listener_arn": { + "type": "arn" + } + } + } + ] +} diff --git a/modules/l1/cloudfront/instance.json b/modules/l1/cloudfront/instance.json new file mode 100644 index 0000000..a0130b3 --- /dev/null +++ b/modules/l1/cloudfront/instance.json @@ -0,0 +1,50 @@ +{ + "version": "1.0.0", + "stack": { + "name": "cloudfront", + "kind": "l1", + "depth": 1 + }, + "resources": [ + { + "id": "cloudfront-originaccesscontrol", + "type": "aws:cloudfront:originaccesscontrol", + "module": "cloudfront@1.0.0", + "inputs": { + "name": "acdl-oac", + "origin_type": "s3", + "signing_behavior": "always", + "region": "us-east-1" + }, + "outputs": { + "oac_id": { + "type": "string" + } + } + }, + { + "id": "cloudfront-distribution", + "type": "aws:cloudfront:distribution", + "module": "cloudfront@1.0.0", + "inputs": { + "bucket_regional_domain_name": "acdl-spike-bucket.s3.us-east-1.amazonaws.com", + "price_class": "PriceClass_100", + "viewer_protocol_policy": "redirect-to-https", + "default_ttl": 3600, + "max_ttl": 86400, + "region": "us-east-1" + }, + "outputs": { + "distribution_arn": { + "type": "arn" + }, + "distribution_domain_name": { + "type": "string" + }, + "oac_id": { + "type": "string" + } + } + } + ] +} diff --git a/modules/l1/ecr/instance.json b/modules/l1/ecr/instance.json new file mode 100644 index 0000000..c7e0fb6 --- /dev/null +++ b/modules/l1/ecr/instance.json @@ -0,0 +1,27 @@ +{ + "version": "1.0.0", + "stack": { + "name": "ecr", + "kind": "l1", + "depth": 1 + }, + "resources": [ + { + "id": "ecr", + "type": "aws:ecr:repository", + "module": "ecr@1.0.0", + "inputs": { + "name": "acdl-demo", + "region": "us-east-1" + }, + "outputs": { + "repository_url": { + "type": "string" + }, + "repository_arn": { + "type": "arn" + } + } + } + ] +} diff --git a/modules/l1/ecs-cluster/instance.json b/modules/l1/ecs-cluster/instance.json new file mode 100644 index 0000000..1927b3e --- /dev/null +++ b/modules/l1/ecs-cluster/instance.json @@ -0,0 +1,27 @@ +{ + "version": "1.0.0", + "stack": { + "name": "ecs-cluster", + "kind": "l1", + "depth": 1 + }, + "resources": [ + { + "id": "ecs-cluster", + "type": "aws:ecs:cluster", + "module": "ecs-cluster@1.0.0", + "inputs": { + "name": "acdl-cluster", + "region": "us-east-1" + }, + "outputs": { + "cluster_arn": { + "type": "arn" + }, + "cluster_id": { + "type": "string" + } + } + } + ] +} diff --git a/modules/l1/ecs-service/instance.json b/modules/l1/ecs-service/instance.json new file mode 100644 index 0000000..a7dff5f --- /dev/null +++ b/modules/l1/ecs-service/instance.json @@ -0,0 +1,41 @@ +{ + "version": "1.0.0", + "stack": { + "name": "ecs-service", + "kind": "l1", + "depth": 1 + }, + "resources": [ + { + "id": "service-taskdefinition", + "type": "aws:ecs:task_definition", + "module": "ecs-service@1.0.0", + "inputs": { + "image": "public.ecr.aws/docker/library/nginx:latest", + "port": 80, + "region": "us-east-1" + }, + "outputs": { + "task_def_arn": { + "type": "arn" + } + } + }, + { + "id": "service-service", + "type": "aws:ecs:service", + "module": "ecs-service@1.0.0", + "inputs": { + "cluster_arn": "arn:aws:ecs:us-east-1:123456789012:cluster/acdl-cluster", + "subnets": "subnet-12345", + "security_group": "sg-12345", + "region": "us-east-1" + }, + "outputs": { + "service_arn": { + "type": "arn" + } + } + } + ] +} diff --git a/modules/l1/iam-role/instance.json b/modules/l1/iam-role/instance.json new file mode 100644 index 0000000..596cbfe --- /dev/null +++ b/modules/l1/iam-role/instance.json @@ -0,0 +1,28 @@ +{ + "version": "1.0.0", + "stack": { + "name": "iam-role", + "kind": "l1", + "depth": 1 + }, + "resources": [ + { + "id": "iam-role", + "type": "aws:iam:role", + "module": "iam-role@1.0.0", + "inputs": { + "role_name": "acdl-task-role", + "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ecs-tasks.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}", + "region": "us-east-1" + }, + "outputs": { + "role_arn": { + "type": "arn" + }, + "role_id": { + "type": "string" + } + } + } + ] +} diff --git a/modules/l1/vpc/instance.json b/modules/l1/vpc/instance.json new file mode 100644 index 0000000..5186260 --- /dev/null +++ b/modules/l1/vpc/instance.json @@ -0,0 +1,50 @@ +{ + "version": "1.0.0", + "stack": { + "name": "vpc", + "kind": "l1", + "depth": 1 + }, + "resources": [ + { + "id": "vpc-vpc", + "type": "aws:ec2:vpc", + "module": "vpc@1.0.0", + "inputs": { + "cidr": "10.0.0.0/16", + "name": "acdl-vpc", + "region": "us-east-1" + }, + "outputs": { + "vpc_id": { + "type": "string" + } + } + }, + { + "id": "vpc-subnet", + "type": "aws:ec2:subnet", + "module": "vpc@1.0.0", + "inputs": { + "cidr": "10.0.1.0/24", + "az": "us-east-1a", + "name": "acdl-vpc", + "region": "us-east-1" + }, + "outputs": { + "subnet_id": { + "type": "string" + } + } + }, + { + "id": "vpc-routetable", + "type": "aws:ec2:routetable", + "module": "vpc@1.0.0", + "inputs": { + "region": "us-east-1" + }, + "outputs": {} + } + ] +} diff --git a/modules/l1/waf/instance.json b/modules/l1/waf/instance.json new file mode 100644 index 0000000..1b8af72 --- /dev/null +++ b/modules/l1/waf/instance.json @@ -0,0 +1,26 @@ +{ + "version": "1.0.0", + "stack": { + "name": "waf", + "kind": "l1", + "depth": 1 + }, + "resources": [ + { + "id": "waf-webacl", + "type": "aws:wafv2:webacl", + "module": "waf@1.0.0", + "inputs": { + "name": "acdl-waf", + "scope": "cloudfront", + "default_action": "allow", + "region": "us-east-1" + }, + "outputs": { + "web_acl_arn": { + "type": "arn" + } + } + } + ] +} diff --git a/scripts/run_pattern_plan.sh b/scripts/run_pattern_plan.sh new file mode 100755 index 0000000..b0fba07 --- /dev/null +++ b/scripts/run_pattern_plan.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Run the platform pipeline for a single pattern (plan-only or check-only). +# +# Usage: run_pattern_plan.sh [--check-only] +# +# --check-only: offline mode (no AWS) — resolves the sample contract for the +# module, runs the adapter, validates the emitted Terraform structure. +# (default): requires AWS — runs terraform plan on the emitted Terraform. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +CHECK_ONLY=0 +MODULE="" + +for arg in "$@"; do + case "$arg" in + --check-only) CHECK_ONLY=1 ;; + --*) echo "FAIL: unknown flag: $arg" >&2; exit 1 ;; + *) MODULE="$arg" ;; + esac +done + +[ -n "$MODULE" ] || { echo "FAIL: module name required" >&2; exit 1; } + +CONTRACT="contracts/$MODULE.yaml" +[ -f "$CONTRACT" ] || { echo "FAIL: no sample contract at $CONTRACT for module '$MODULE'" >&2; exit 1; } + +WORK="/tmp/acdl_pattern_plan_$MODULE" +rm -rf "$WORK"; mkdir -p "$WORK" + +echo "=== Pattern plan: $MODULE ===" +echo "" +echo "--- Step 1: environment check ---" +python3 core/environment_check.py "$CONTRACT" || { echo "FAIL: environment not bound" >&2; exit 1; } + +echo "" +echo "--- Step 2: validate contract ---" +python3 -c " +import json, yaml, jsonschema +schema = json.load(open('schemas/contract.schema.json')) +contract = yaml.safe_load(open('$CONTRACT')) +jsonschema.validate(contract, schema) +print(f'contract: module={contract[\"module\"]} env={contract[\"environment\"]}') +" + +echo "" +echo "--- Step 3: resolve contract -> stack ---" +python3 core/contract_resolver.py "$CONTRACT" "$WORK/stack.json" || { echo "FAIL: resolver failed" >&2; exit 1; } +python3 -c "import json; d=json.load(open('$WORK/stack.json')); print(f'stack: {d[\"stack\"][\"name\"]} {len(d[\"resources\"])} resource(s)')" + +echo "" +echo "--- Step 4: adapter compiles stack -> terraform ---" +OUT_DIR="$WORK/terraform" +python3 adapters/terraform/adapter.py "$WORK/stack.json" "$OUT_DIR" || { echo "FAIL: adapter failed" >&2; exit 1; } +echo "adapter: emitted $OUT_DIR/{main.tf,terraform.tf,providers.tf}" + +if [ "$CHECK_ONLY" = "1" ]; then + echo "" + echo "--- Step 5: validate adapter output structure (offline) ---" + python3 -c " +import json, os +d = json.load(open('$WORK/stack.json')) +assert d['stack']['kind'] == 'l2', f\"expected l2, got {d['stack']['kind']}\" +assert len(d['resources']) >= 1 +tf_dir = '$OUT_DIR' +for f in ('main.tf', 'terraform.tf', 'providers.tf'): + assert os.path.isfile(os.path.join(tf_dir, f)), f'{f} missing' +main = open(os.path.join(tf_dir, 'main.tf')).read() +assert len(main) > 0, 'main.tf is empty' +print(f'pattern $MODULE: adapter output OK ({len(d[\"resources\"])} resource(s))') +" + echo "" + echo "=== PATTERN CHECK OK ($MODULE) ===" + exit 0 +fi + +echo "" +echo "--- Step 5: terraform init + validate + plan ---" +cd "$OUT_DIR" +terraform init -backend=false -input=false +terraform validate +terraform plan -lock=false -input=false -out=tfplan +echo "=== PATTERN PLAN OK ($MODULE) ===" \ No newline at end of file diff --git a/scripts/run_primitive_plan.sh b/scripts/run_primitive_plan.sh new file mode 100755 index 0000000..aea9ad9 --- /dev/null +++ b/scripts/run_primitive_plan.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Run the platform pipeline for a single primitive (plan-only or check-only). +# +# Usage: run_primitive_plan.sh [--check-only] +# +# --check-only: offline mode (no AWS) — resolves the primitive's instance.json, +# runs the adapter, validates the emitted Terraform structure. +# (default): requires AWS — runs terraform plan on the emitted Terraform. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +CHECK_ONLY=0 +PRIMITIVE="" + +for arg in "$@"; do + case "$arg" in + --check-only) CHECK_ONLY=1 ;; + --*) echo "FAIL: unknown flag: $arg" >&2; exit 1 ;; + *) PRIMITIVE="$arg" ;; + esac +done + +[ -n "$PRIMITIVE" ] || { echo "FAIL: primitive name required" >&2; exit 1; } + +INSTANCE="modules/l1/$PRIMITIVE/instance.json" +[ -f "$INSTANCE" ] || { echo "FAIL: no instance.json for primitive '$PRIMITIVE'" >&2; exit 1; } + +WORK="/tmp/acdl_primitive_plan_$PRIMITIVE" +rm -rf "$WORK"; mkdir -p "$WORK" + +echo "=== Primitive plan: $PRIMITIVE ===" +echo "" +echo "--- Step 1: adapter compiles instance -> terraform ---" +OUT_DIR="$WORK/terraform" +python3 adapters/terraform/adapter.py "$INSTANCE" "$OUT_DIR" || { echo "FAIL: adapter failed" >&2; exit 1; } +echo "adapter: emitted $OUT_DIR/{main.tf,terraform.tf,providers.tf}" + +if [ "$CHECK_ONLY" = "1" ]; then + echo "" + echo "--- Step 2: validate adapter output structure (offline) ---" + python3 -c " +import json, os +d = json.load(open('$INSTANCE')) +assert d['stack']['kind'] == 'l1', f\"expected l1, got {d['stack']['kind']}\" +assert len(d['resources']) >= 1 +tf_dir = '$OUT_DIR' +for f in ('main.tf', 'terraform.tf', 'providers.tf'): + assert os.path.isfile(os.path.join(tf_dir, f)), f'{f} missing' +main = open(os.path.join(tf_dir, 'main.tf')).read() +assert len(main) > 0, 'main.tf is empty' +print(f'primitive $PRIMITIVE: adapter output OK ({len(d[\"resources\"])} resource(s))') +" + echo "" + echo "=== PRIMITIVE CHECK OK ($PRIMITIVE) ===" + exit 0 +fi + +echo "" +echo "--- Step 2: terraform init + validate + plan ---" +cd "$OUT_DIR" +terraform init -backend=false -input=false +terraform validate +terraform plan -lock=false -input=false -out=tfplan +echo "=== PRIMITIVE PLAN OK ($PRIMITIVE) ===" \ No newline at end of file diff --git a/tests/test_pipeline_contract.py b/tests/test_pipeline_contract.py index 99d1f9b..48a0461 100644 --- a/tests/test_pipeline_contract.py +++ b/tests/test_pipeline_contract.py @@ -360,4 +360,134 @@ class TestSampleContractVersioning: uses = contract["uses"] assert "@v" in uses, "sample contract must use a versioned @vX.Y tag" assert "@main" not in uses, "sample contract must not use @main" - assert uses == "acdl/pipelines/deploy.yaml@v1.6" \ No newline at end of file + assert uses == "acdl/pipelines/deploy.yaml@v1.6" + + +class TestPlatformWorkflows: + """Validate the Phase 26 platform pipelines exist and conform.""" + + def test_platform_test_workflow_exists(self): + assert (ROOT / ".github/workflows/platform-test.yml").is_file() + + def test_primitives_plan_workflow_exists(self): + assert (ROOT / ".github/workflows/primitives-plan.yml").is_file() + + def test_patterns_plan_workflow_exists(self): + assert (ROOT / ".github/workflows/patterns-plan.yml").is_file() + + def test_release_workflow_exists(self): + assert (ROOT / ".github/workflows/release.yml").is_file() + + def test_platform_test_has_four_stages(self): + wf = _load_workflow(".github/workflows/platform-test.yml") + job_names = set(wf["jobs"].keys()) + assert job_names == {"lint", "unit-test", "integration-test", "schema-validation"} + + def test_platform_test_lint_compiles_python(self): + wf = _load_workflow(".github/workflows/platform-test.yml") + lint_job = wf["jobs"]["lint"] + run_step = next(s for s in lint_job["steps"] if "run" in s) + assert "py_compile" in run_step["run"] + for py_file in [ + "core/confidence_signal.py", + "core/outbox_writer.py", + "core/contract_resolver.py", + "core/environment_check.py", + "core/output_publisher.py", + "core/lambda/contract_ingestor.py", + "adapters/terraform/adapter.py", + "adapters/terraform/policy/checkov_adapter.py", + "adapters/wiz/wiz_adapter.py", + "adapters/kyverno/kyverno_adapter.py", + "scripts/push_consumer_image.py", + ]: + assert py_file in run_step["run"], f"{py_file} missing from platform-test lint" + + def test_platform_test_unit_test_runs_pytest(self): + wf = _load_workflow(".github/workflows/platform-test.yml") + test_job = wf["jobs"]["unit-test"] + run_step = next(s for s in test_job["steps"] if "run" in s and "pytest" in s["run"]) + assert "pytest" in run_step["run"] + + def test_platform_test_integration_runs_all_contracts(self): + wf = _load_workflow(".github/workflows/platform-test.yml") + integ_job = wf["jobs"]["integration-test"] + run_step = next( + s for s in integ_job["steps"] if "run" in s and "run_platform" in s["run"] + ) + assert "run_platform.sh" in run_step["run"] + assert "--check-only" in run_step["run"] + assert "contracts/*.yaml" in run_step["run"] + + def test_platform_test_schema_validation_validates_schemas(self): + wf = _load_workflow(".github/workflows/platform-test.yml") + schema_job = wf["jobs"]["schema-validation"] + steps_text = " ".join(s.get("run", "") for s in schema_job["steps"]) + assert "jsonschema" in steps_text + assert "stack.schema.json" in steps_text + + def test_platform_test_triggers_pr_only(self): + wf = _load_workflow(".github/workflows/platform-test.yml") + assert "pull_request" in wf["on"] + assert "main" in wf["on"]["pull_request"]["branches"] + # platform-test should NOT trigger on push (ci.yml handles push-to-main) + assert "push" not in wf["on"] + + def test_primitives_plan_has_matrix_with_all_l1_primitives(self): + wf = _load_workflow(".github/workflows/primitives-plan.yml") + job = wf["jobs"]["primitive-plan"] + matrix = job["strategy"]["matrix"] + expected = ["s3", "vpc", "ecs-cluster", "ecs-service", "iam-role", "alb", "ecr", "cloudfront", "waf"] + assert sorted(matrix["primitive"]) == sorted(expected) + + def test_primitives_plan_runs_run_primitive_plan(self): + wf = _load_workflow(".github/workflows/primitives-plan.yml") + job = wf["jobs"]["primitive-plan"] + run_step = next(s for s in job["steps"] if "run" in s and "run_primitive_plan" in s["run"]) + assert "run_primitive_plan.sh" in run_step["run"] + assert "--check-only" in run_step["run"] + + def test_primitives_plan_triggers_pr_only(self): + wf = _load_workflow(".github/workflows/primitives-plan.yml") + assert "pull_request" in wf["on"] + assert "main" in wf["on"]["pull_request"]["branches"] + assert "push" not in wf["on"] + + def test_patterns_plan_has_matrix_with_all_l2_modules(self): + wf = _load_workflow(".github/workflows/patterns-plan.yml") + job = wf["jobs"]["pattern-plan"] + matrix = job["strategy"]["matrix"] + expected = ["static-assets", "microservice"] + assert sorted(matrix["module"]) == sorted(expected) + + def test_patterns_plan_runs_run_pattern_plan(self): + wf = _load_workflow(".github/workflows/patterns-plan.yml") + job = wf["jobs"]["pattern-plan"] + run_step = next(s for s in job["steps"] if "run" in s and "run_pattern_plan" in s["run"]) + assert "run_pattern_plan.sh" in run_step["run"] + assert "--check-only" in run_step["run"] + + def test_patterns_plan_triggers_pr_only(self): + wf = _load_workflow(".github/workflows/patterns-plan.yml") + assert "pull_request" in wf["on"] + assert "main" in wf["on"]["pull_request"]["branches"] + assert "push" not in wf["on"] + + def test_release_workflow_triggers_push_main(self): + wf = _load_workflow(".github/workflows/release.yml") + assert "push" in wf["on"] + assert "main" in wf["on"]["push"]["branches"] + + def test_release_workflow_has_contents_write_permission(self): + wf = _load_workflow(".github/workflows/release.yml") + # permissions are declared at the job level (the release job) + release_job = wf["jobs"]["release"] + assert release_job["permissions"]["contents"] == "write" + + def test_release_workflow_fetch_depth_zero(self): + wf = _load_workflow(".github/workflows/release.yml") + release_job = wf["jobs"]["release"] + checkout = next( + s for s in release_job["steps"] if "checkout" in s.get("uses", "") + ) + assert checkout["with"]["fetch-depth"] == 0 \ No newline at end of file diff --git a/tests/test_release_logic.py b/tests/test_release_logic.py new file mode 100644 index 0000000..733ff30 --- /dev/null +++ b/tests/test_release_logic.py @@ -0,0 +1,48 @@ +"""Test the release semver computation logic (D-057).""" + +import pytest + + +def compute_next_version(latest_tag, is_milestone_complete): + """Compute the next semver tag from the latest tag + milestone flag.""" + parts = latest_tag.lstrip("v").split(".") + major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2]) + if is_milestone_complete: + minor += 1 + patch = 0 + else: + patch += 1 + new_tag = f"v{major}.{minor}.{patch}" + major_minor_tag = f"v{major}.{minor}" + major_tag = f"v{major}" + return new_tag, major_minor_tag, major_tag + + +class TestComputeNextVersion: + def test_regular_phase_bumps_patch(self): + assert compute_next_version("v1.6.0", False) == ("v1.6.1", "v1.6", "v1") + + def test_milestone_complete_bumps_minor(self): + assert compute_next_version("v1.6.5", True) == ("v1.7.0", "v1.7", "v1") + + def test_milestone_complete_resets_patch(self): + assert compute_next_version("v1.6.0", True) == ("v1.7.0", "v1.7", "v1") + + def test_major_bump(self): + # Not implemented yet (major milestones are manual), but test the tag format + tag, mj, m = compute_next_version("v2.3.4", True) + assert tag == "v2.4.0" + assert mj == "v2.4" + assert m == "v2" + + def test_floating_tags_match_major_minor(self): + tag, mj, m = compute_next_version("v1.7.3", False) + assert mj == "v1.7" + assert m == "v1" + assert tag == "v1.7.4" + + def test_v1_tag_advances_with_minor(self): + # When minor bumps, v1 tag still points at the latest (force-moved) + tag, mj, m = compute_next_version("v1.6.5", True) + assert m == "v1" + assert tag == "v1.7.0" \ No newline at end of file