diff --git a/.gitignore b/.gitignore index 23b879b..f053016 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,7 @@ audit.json .DS_Store runner-data/ .env.secrets -terraform/bootstrap/.bootstrap_state.json \ No newline at end of file +terraform/bootstrap/.bootstrap_state.json +terraform/spike/.terraform/ +terraform/spike/tfplan +terraform/spike/*.tfstate* \ No newline at end of file diff --git a/adapters/terraform/adapter.py b/adapters/terraform/adapter.py new file mode 100644 index 0000000..bf0a591 --- /dev/null +++ b/adapters/terraform/adapter.py @@ -0,0 +1,131 @@ +"""ACDL Terraform adapter — compile a Target Stack IR instance to Terraform. + +ARCHITECTURE.md §12.2: the adapter translates the IR-typed L1 interface +to a Terraform variable/output block, the L2 thin-composition tree to a +root module that calls the L1 modules, the IR-typed relationships to +Terraform module references, and emits a Terraform plan from the IR. + +The adapter is a THIN LAYER; it does not own L1/L2 content — it only +translates. Substrate-agnostic in, Terraform out. + +Spike scope (Phase 09): handles one L1 (l1-s3, IR type aws:s3:bucket). +L2 thin-composition + relationships land in Phase 10. + +CLI: adapter.py +""" + +import json +import os +import sys + + +# IR type -> Terraform resource type. The only substrate-specific table. +# As more L1s land, this grows; the L1 content + IR do not change. +TYPE_MAP = { + "aws:s3:bucket": "aws_s3_bucket", +} + + +def _tf_block(block_type, name, body_lines, indent=2): + head = f'{block_type} "{name}" {{' + body = "\n".join(f" {l}" for l in body_lines) + return f"{head}\n{body}\n}}\n" + + +def _emit_resource(resource): + rtype = resource["type"] + rid = resource["id"] + tf_type = TYPE_MAP.get(rtype) + if not tf_type: + raise ValueError(f"unknown IR type {rtype!r} (adapter spike handles aws:s3:bucket only)") + body = [] + inputs = resource.get("inputs", {}) + # S3 bucket: bucket_name -> bucket arg; region -> provider (handled separately) + if "bucket_name" in inputs: + body.append(f'bucket = "{inputs["bucket_name"]}"') + # NFR: versioning (default true) + nfrs = resource.get("nfrs", {}) + versioning = nfrs.get("versioning", True) if isinstance(nfrs, dict) else True + body.append("versioning {") + body.append(f' enabled = {"true" if versioning else "false"}') + body.append("}") + return _tf_block("resource", f'aws_s3_bucket.{rid}', body) if False else _resource_block(rid, tf_type, body) + + +def _resource_block(rid, tf_type, body): + """Emit a top-level resource block.""" + head = f'resource "{tf_type}" "{rid}" {{' + body_str = "\n".join(f" {l}" for l in body) + return f"{head}\n{body_str}\n}}\n" + + +def _emit_output(output_name, value_expr): + return f'output "{output_name}" {{\n value = {value_expr}\n}}\n' + + +def adapt(ir_instance, out_dir): + """Emit main.tf + terraform.tf + providers.tf to out_dir for the IR instance.""" + os.makedirs(out_dir, exist_ok=True) + stack = ir_instance["stack"] + resources = ir_instance["resources"] + + # --- providers.tf: aws provider, region from the first resource's inputs.region --- + region = "us-east-1" + for r in resources: + if "region" in r.get("inputs", {}): + region = r["inputs"]["region"] + break + providers_tf = ( + f'provider "aws" {{\n' + f' region = "{region}"\n' + f'}}\n' + ) + + # --- terraform.tf: required_version + required_providers + S3 backend (no DynamoDB lock per D-P09-1) --- + terraform_tf = ( + 'terraform {\n' + ' required_version = ">= 1.9, < 1.10"\n' + ' required_providers {\n' + ' aws = {\n' + ' source = "hashicorp/aws"\n' + ' version = "~> 5.0"\n' + ' }\n' + ' }\n' + ' backend "s3" {\n' + ' bucket = "acdl-tfstate-581513795199-us-east-1"\n' + ' key = "spike/l1-s3/terraform.tfstate"\n' + ' region = "us-east-1"\n' + ' }\n' + '}\n' + ) + + # --- main.tf: resources + outputs --- + main_tf_parts = [] + for r in resources: + main_tf_parts.append(_emit_resource(r)) + rid = r["id"] + outputs = r.get("outputs", {}) + for out_name in outputs: + if out_name == "bucket_arn": + main_tf_parts.append(_emit_output("bucket_arn", f"aws_s3_bucket.{rid}.arn")) + elif out_name == "bucket_name": + main_tf_parts.append(_emit_output("bucket_name", f"aws_s3_bucket.{rid}.id")) + main_tf = "\n".join(main_tf_parts) + + with open(os.path.join(out_dir, "main.tf"), "w") as fh: + fh.write(main_tf) + with open(os.path.join(out_dir, "terraform.tf"), "w") as fh: + fh.write(terraform_tf) + with open(os.path.join(out_dir, "providers.tf"), "w") as fh: + fh.write(providers_tf) + return out_dir + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("usage: adapter.py ", file=sys.stderr) + sys.exit(2) + with open(sys.argv[1], "r") as fh: + ir = json.load(fh) + adapt(ir, sys.argv[2]) + print(f"adapter: emitted terraform to {sys.argv[2]}", file=sys.stderr) \ No newline at end of file diff --git a/scripts/run_spike_plan.sh b/scripts/run_spike_plan.sh new file mode 100755 index 0000000..7fc38a5 --- /dev/null +++ b/scripts/run_spike_plan.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# scripts/run_spike_plan.sh - run the v1.1 spike's real terraform plan against AWS. +# +# Uses the rotated spike key (D-039) from gitignored .env.secrets. +# Plan-only (no apply); -lock=false per D-P09-1 (the spike's DynamoDB +# outbox table PK is contractId, not Terraform's expected LockID; plan +# does not write state so locking is unnecessary; v1.2 creates a proper +# LockID-keyed acdl-tflock table). +set -u +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +ENV_FILE="$ROOT/.env.secrets" +[ -f "$ENV_FILE" ] || { echo "FAIL: .env.secrets missing (run scripts/rotate_spike_key.sh)" >&2; exit 1; } +set -a +. "$ENV_FILE" +set +a +export AWS_ACCESS_KEY_ID="$ACDL_AWS_ACCESS_KEY_ID" +export AWS_SECRET_ACCESS_KEY="$ACDL_AWS_SECRET_ACCESS_KEY" +export AWS_DEFAULT_REGION="$AWS_DEFAULT_REGION" + +cd terraform/spike +echo "=== terraform init -lock=false -input=false ===" +terraform init -lock=false -input=false +echo "=== terraform validate ===" +terraform validate +echo "=== terraform plan -lock=false -input=false -out=tfplan ===" +terraform plan -lock=false -input=false -out=tfplan +echo "spike plan OK" \ No newline at end of file diff --git a/terraform/spike/main.tf b/terraform/spike/main.tf new file mode 100644 index 0000000..d750400 --- /dev/null +++ b/terraform/spike/main.tf @@ -0,0 +1,14 @@ +resource "aws_s3_bucket" "s3" { + bucket = "acdl-spike-bucket" + versioning { + enabled = true + } +} + +output "bucket_arn" { + value = aws_s3_bucket.s3.arn +} + +output "bucket_name" { + value = aws_s3_bucket.s3.id +} diff --git a/terraform/spike/providers.tf b/terraform/spike/providers.tf new file mode 100644 index 0000000..c125940 --- /dev/null +++ b/terraform/spike/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = "us-east-1" +} diff --git a/terraform/spike/terraform.tf b/terraform/spike/terraform.tf new file mode 100644 index 0000000..c1d14ab --- /dev/null +++ b/terraform/spike/terraform.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.9, < 1.10" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + backend "s3" { + bucket = "acdl-tfstate-581513795199-us-east-1" + key = "spike/l1-s3/terraform.tfstate" + region = "us-east-1" + } +}