diff --git a/.github/workflows/primitives-plan.yml b/.github/workflows/primitives-plan.yml index 25fa76a..4980ef0 100644 --- a/.github/workflows/primitives-plan.yml +++ b/.github/workflows/primitives-plan.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - primitive: [s3, vpc, ecs-cluster, ecs-service, iam-role, alb, ecr, cloudfront, waf] + primitive: [s3, vpc, ecs-cluster, ecs-service, iam-role, alb, ecr, cloudfront, waf, rds] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/adapters/terraform/adapter.py b/adapters/terraform/adapter.py index abba509..3a5be27 100644 --- a/adapters/terraform/adapter.py +++ b/adapters/terraform/adapter.py @@ -39,6 +39,7 @@ TYPE_MAP = { "aws:cloudfront:distribution": "aws_cloudfront_distribution", "aws:cloudfront:originaccesscontrol": "aws_cloudfront_origin_access_control", "aws:wafv2:webacl": "aws_wafv2_web_acl", + "aws:rds:instance": "aws_db_instance", } # Stack input name -> Terraform arg name, per stack type. Only non-identity @@ -60,6 +61,7 @@ INPUT_MAP = { "aws:cloudfront:distribution": {"bucket_regional_domain_name": "origin_domain_name", "price_class": "price_class", "viewer_protocol_policy": "viewer_protocol_policy", "default_ttl": "default_ttl", "max_ttl": "max_ttl", "waf_web_acl_arn": "web_acl_id"}, "aws:cloudfront:originaccesscontrol": {"name": "name", "origin_type": "origin_access_control_origin_type", "signing_behavior": "origin_access_control_signing_behavior"}, "aws:wafv2:webacl": {"name": "name", "scope": "scope", "default_action": "default_action", "rules": "rules"}, + "aws:rds:instance": {"db_name": "db_name", "instance_class": "instance_class", "allocated_storage": "allocated_storage", "engine": "engine", "engine_version": "engine_version", "username": "username", "multi_az": "multi_az", "storage_encrypted": "storage_encrypted"}, } # Stack output name -> Terraform attribute name, per stack type. Only @@ -81,6 +83,7 @@ OUTPUT_MAP = { "aws:cloudfront:distribution": {"distribution_arn": "arn", "distribution_domain_name": "domain_name", "oac_id": "origin_access_control_id"}, "aws:cloudfront:originaccesscontrol": {"oac_id": "id"}, "aws:wafv2:webacl": {"web_acl_arn": "arn"}, + "aws:rds:instance": {"db_endpoint": "endpoint", "db_arn": "arn"}, } @@ -362,6 +365,22 @@ def _emit_resource(resource, type_by_id=None): body.append(" sampled_requests_enabled = true") body.append(" }") body.append("}") + if rtype == "aws:rds:instance": + # Emit NFR-derived arguments: backup_retention_period + + # deletion_protection from the nfrs block. Also emit + # storage_encrypted = true (from inputs, already emitted above if + # present) and skip_final_snapshot = true for dev safety. + nfrs = resource.get("nfrs", {}) + backup_retention = nfrs.get("backup_retention_period", 7) + deletion_protection = nfrs.get("deletion_protection", True) + body.append(f"backup_retention_period = {_tf_value(backup_retention)}") + body.append(f"deletion_protection = {_tf_value(deletion_protection)}") + # Ensure storage_encrypted is emitted (defaults to true if not in inputs). + if "storage_encrypted" not in inputs: + body.append("storage_encrypted = true") + # Dev safety: skip the final snapshot so `terraform destroy` works + # without a final DB snapshot (overridden by deletion_protection). + body.append("skip_final_snapshot = true") return _resource_block(rid, tf_type, body) diff --git a/modules/l1/rds/README.md b/modules/l1/rds/README.md new file mode 100644 index 0000000..75f7e75 --- /dev/null +++ b/modules/l1/rds/README.md @@ -0,0 +1,94 @@ +# rds — RDS database instance + +> **Module kind:** primitive | **Version:** 1.0.0 + +An RDS database instance. Supports multiple database engines (postgres, +mysql, mariadb, sqlserver, oracle) via the `engine` input. The adapter +translates the substrate-agnostic `aws:rds:instance` stack type to the +Terraform `aws_db_instance` resource. + +## Resources + +| Resource | Type | Purpose | +|----------|------|---------| +| `rds` | `aws_db_instance` | The RDS database instance | + +## Inputs + +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `engine` | string | yes | — | Database engine (postgres, mysql, mariadb, sqlserver-ex, sqlserver-web, sqlserver-se, oracle-se2) | +| `engine_version` | string | yes | — | Engine version (e.g. 16.4 for postgres, 8.4 for mysql) | +| `instance_class` | string | yes | `db.t3.micro` | RDS instance class (e.g. db.t3.micro, db.r6g.large) | +| `allocated_storage` | number | no | 20 | Allocated storage in GB | +| `db_name` | string | yes | — | The database name (some engines have restrictions) | +| `username` | string | yes | — | Master username | +| `multi_az` | boolean | no | false | Multi-AZ deployment | +| `storage_encrypted` | boolean | no | true | Enable storage encryption | +| `region` | string | yes | — | AWS region the RDS instance is created in | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| `db_endpoint` | string | The RDS instance endpoint (host:port) | +| `db_arn` | arn | The RDS instance ARN | + +## NFRs + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| `backup_retention_period` | number | 7 | Backup retention period in days | +| `deletion_protection` | boolean | true | Enable deletion protection (default true for prod) | + +## Usage + +```json +{ + "id": "rds", + "type": "aws:rds:instance", + "module": "rds@1.0.0", + "inputs": { + "engine": "postgres", + "engine_version": "16.4", + "instance_class": "db.t3.micro", + "allocated_storage": 20, + "db_name": "acdl_db", + "username": "acdl_admin", + "multi_az": false, + "storage_encrypted": true, + "region": "us-east-1" + }, + "nfrs": { + "backup_retention_period": 7, + "deletion_protection": true + } +} +``` + +A concrete instance is at `instance.json` (used by the platform pipeline +as the regression baseline). + +## Compliance extension points + +- **KMS encryption** — add a customer-managed KMS key for storage + encryption (`kms_key_id` argument) (SOC2 CC6.1, HIPAA §164.312(a)(2)(iv), + GDPR Art.32). +- **Automated backups** — the `backup_retention_period` NFR controls + automated backup retention; extend with backup windows + copy tags to + another region for DR (SOX ITGC, DORA operational resilience). +- **Audit logging via CloudTrail** — RDS does not emit CloudTrail events + for data-plane access; add `aws_db_instance_automated_backups_replication` + + CloudWatch Logs for database audit (SOX, SOC2 CC7.2, HIPAA + §164.312(b)). +- **IAM database authentication** — add `iam_database_authentication_enabled + = true` so IAM users/roles can authenticate to the database without + long-lived passwords (SOC2 CC6.1, HIPAA §164.308(a)(4)). +- **Read replicas** — add `aws_db_instance` with `replicate_source_db` for + read scaling and DR failover (SOC2 CC9.1, DORA operational resilience). + +## Versioning + +`1.0.0` — interface MAJOR, behavior MINOR, lifecycle PATCH. MAJOR bumps +require a new registry entry (immutable publication); old entries enter +a 12-month deprecation window. \ No newline at end of file diff --git a/modules/l1/rds/instance.json b/modules/l1/rds/instance.json new file mode 100644 index 0000000..c4a5ddd --- /dev/null +++ b/modules/l1/rds/instance.json @@ -0,0 +1,34 @@ +{ + "version": "1.0.0", + "stack": { + "name": "rds", + "kind": "l1", + "depth": 1 + }, + "resources": [ + { + "id": "rds", + "type": "aws:rds:instance", + "module": "rds@1.0.0", + "inputs": { + "engine": "postgres", + "engine_version": "16.4", + "instance_class": "db.t3.micro", + "allocated_storage": 20, + "db_name": "acdl_db", + "username": "acdl_admin", + "multi_az": false, + "storage_encrypted": true, + "region": "us-east-1" + }, + "outputs": { + "db_endpoint": {"type": "string", "description": "The RDS instance endpoint."}, + "db_arn": {"type": "arn", "description": "The RDS instance ARN."} + }, + "nfrs": { + "backup_retention_period": 7, + "deletion_protection": true + } + } + ] +} \ No newline at end of file diff --git a/modules/l1/rds/interface.json b/modules/l1/rds/interface.json new file mode 100644 index 0000000..4b04254 --- /dev/null +++ b/modules/l1/rds/interface.json @@ -0,0 +1,81 @@ +{ + "name": "rds", + "version": "1.0.0", + "kind": "l1", + "type": "aws:rds:instance", + "description": "RDS database instance primitive (substrate-agnostic stack type aws:rds:instance; the Terraform adapter translates to aws_db_instance). Supports multiple engines (postgres, mysql, etc.) via the engine input.", + "inputs": { + "engine": { + "type": "string", + "description": "Database engine (postgres, mysql, mariadb, sqlserver, oracle).", + "required": true, + "enum": ["postgres", "mysql", "mariadb", "sqlserver-ex", "sqlserver-web", "sqlserver-se", "oracle-se2"] + }, + "engine_version": { + "type": "string", + "description": "Engine version (e.g. 16.4 for postgres, 8.4 for mysql).", + "required": true + }, + "instance_class": { + "type": "string", + "description": "RDS instance class (e.g. db.t3.micro, db.r6g.large).", + "required": true, + "default": "db.t3.micro" + }, + "allocated_storage": { + "type": "number", + "description": "Allocated storage in GB (default 20).", + "required": false, + "default": 20 + }, + "db_name": { + "type": "string", + "description": "The database name (some engines have restrictions).", + "required": true + }, + "username": { + "type": "string", + "description": "Master username.", + "required": true + }, + "multi_az": { + "type": "boolean", + "description": "Multi-AZ deployment (default false).", + "required": false, + "default": false + }, + "storage_encrypted": { + "type": "boolean", + "description": "Enable storage encryption (default true).", + "required": false, + "default": true + }, + "region": { + "type": "string", + "description": "AWS region the RDS instance is created in.", + "required": true + } + }, + "outputs": { + "db_endpoint": { + "type": "string", + "description": "The RDS instance endpoint (host:port)." + }, + "db_arn": { + "type": "arn", + "description": "The RDS instance ARN." + } + }, + "nfrs": { + "backup_retention_period": { + "type": "number", + "description": "Backup retention period in days (default 7).", + "default": 7 + }, + "deletion_protection": { + "type": "boolean", + "description": "Enable deletion protection (default true for prod).", + "default": true + } + } +} \ No newline at end of file diff --git a/modules/registry.json b/modules/registry.json index 1f23973..f35f44c 100644 --- a/modules/registry.json +++ b/modules/registry.json @@ -62,6 +62,13 @@ "deprecated": false } }, + "rds": { + "1.0.0": { + "interface": "modules/l1/rds/interface.json", + "published_at": "2026-07-22T20:00:00Z", + "deprecated": false + } + }, "static-assets": { "1.0.0": { "interface": "modules/l2/static-assets/composition.json", diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 13a4931..f49b54a 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -31,14 +31,14 @@ class TestInstance: class TestRegistry: - EXPECTED_L1_KEYS = {"s3", "vpc", "ecs-cluster", "ecs-service", "iam-role", "alb", "ecr", "cloudfront", "waf"} + EXPECTED_L1_KEYS = {"s3", "vpc", "ecs-cluster", "ecs-service", "iam-role", "alb", "ecr", "cloudfront", "waf", "rds"} EXPECTED_L2_KEYS = {"static-assets", "microservice"} - def test_registry_has_11_entries(self, registry): - assert len(registry) == 11 + def test_registry_has_12_entries(self, registry): + assert len(registry) == 12 assert set(registry.keys()) == (self.EXPECTED_L1_KEYS | self.EXPECTED_L2_KEYS) - def test_registry_has_9_l1_entries(self, registry): + def test_registry_has_10_l1_entries(self, registry): l1 = {k for k in registry if registry[k]["1.0.0"]["interface"].startswith("modules/l1/")} assert l1 == self.EXPECTED_L1_KEYS @@ -85,6 +85,9 @@ class TestTypeMap: def test_waf_type_in_type_map(self): assert TYPE_MAP["aws:wafv2:webacl"] == "aws_wafv2_web_acl" + def test_rds_type_in_type_map(self): + assert TYPE_MAP["aws:rds:instance"] == "aws_db_instance" + class TestTfValue: def test_string_quoted(self): @@ -196,6 +199,46 @@ class TestS3Output: assert "aws_s3_bucket.s3.bucket_regional_domain_name" in main_tf +class TestRdsPrimitive: + @pytest.fixture + def rds_stack(self): + return json.load(open(ROOT / "modules/l1/rds/instance.json")) + + def test_rds_instance_validates_against_stack_schema(self, rds_stack, stack_schema): + jsonschema.validate(rds_stack, stack_schema) + + def test_rds_adapt_emits_db_instance(self, rds_stack, tmp_path): + out_dir = str(tmp_path / "tf_out") + adapt(rds_stack, out_dir) + main_tf = open(os.path.join(out_dir, "main.tf")).read() + assert 'resource "aws_db_instance" "rds"' in main_tf + + def test_rds_adapt_emits_engine_and_class(self, rds_stack, tmp_path): + out_dir = str(tmp_path / "tf_out") + adapt(rds_stack, out_dir) + main_tf = open(os.path.join(out_dir, "main.tf")).read() + assert 'engine = "postgres"' in main_tf + assert 'engine_version = "16.4"' in main_tf + assert 'instance_class = "db.t3.micro"' in main_tf + + def test_rds_adapt_emits_nfrs(self, rds_stack, tmp_path): + out_dir = str(tmp_path / "tf_out") + adapt(rds_stack, out_dir) + main_tf = open(os.path.join(out_dir, "main.tf")).read() + assert "backup_retention_period = 7" in main_tf + assert "deletion_protection = true" in main_tf + assert "skip_final_snapshot = true" in main_tf + + def test_rds_adapt_emits_outputs(self, rds_stack, tmp_path): + out_dir = str(tmp_path / "tf_out") + adapt(rds_stack, out_dir) + main_tf = open(os.path.join(out_dir, "main.tf")).read() + assert 'output "db_endpoint"' in main_tf + assert 'output "db_arn"' in main_tf + assert "aws_db_instance.rds.endpoint" in main_tf + assert "aws_db_instance.rds.arn" in main_tf + + class TestStaticAssetsStack: @pytest.fixture def static_assets_stack(self): diff --git a/tests/test_pipeline_contract.py b/tests/test_pipeline_contract.py index 48a0461..f9d96cc 100644 --- a/tests/test_pipeline_contract.py +++ b/tests/test_pipeline_contract.py @@ -437,7 +437,7 @@ class TestPlatformWorkflows: 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"] + expected = ["s3", "vpc", "ecs-cluster", "ecs-service", "iam-role", "alb", "ecr", "cloudfront", "waf", "rds"] assert sorted(matrix["primitive"]) == sorted(expected) def test_primitives_plan_runs_run_primitive_plan(self):