diff --git a/modules/README-TEMPLATE.md b/modules/README-TEMPLATE.md index f046285..7df1372 100644 --- a/modules/README-TEMPLATE.md +++ b/modules/README-TEMPLATE.md @@ -28,6 +28,18 @@ Terraform resources this module creates: |------|------|-------------| | `<name>` | string | description | +## NFRs + +Non-functional requirements declared by the module's interface. Every +L1 primitive MUST declare `deletion_protection` and `encryption_enabled` +(both boolean, default `true`); they are mandatory NFRs for every L1. + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| `deletion_protection` | boolean | true | Prevent resource destruction via Terraform lifecycle prevent_destroy. | +| `encryption_enabled` | boolean | true | Enable encryption (at rest or in transit, as applicable). | +| `<name>` | <type> | <default> | description | + ## Usage ``` diff --git a/modules/README.md b/modules/README.md index 334356e..f25a6bb 100644 --- a/modules/README.md +++ b/modules/README.md @@ -33,6 +33,9 @@ resources it creates. | `ecr` | `aws_ecr_repository` — ECR container image repository | [README](l1/ecr/README.md) | | `cloudfront` | `aws_cloudfront_distribution` + `aws_cloudfront_origin_access_control` — CloudFront distribution with S3 origin via OAC | [README](l1/cloudfront/README.md) | | `waf` | `aws_wafv2_web_acl` — WAFv2 Web ACL (CloudFront-scoped) | [README](l1/waf/README.md) | +| `rds` | `aws_db_instance` — Relational database (PostgreSQL, MySQL, etc.) with multi-engine support | [README](l1/rds/README.md) | +| `kms-key` | `aws_kms_key` — Customer-managed KMS key with rotation enabled (per-stack CMK) | [README](l1/kms-key/README.md) | +| `uptime` | `aws_ecs_service` — Uptime-kuma monitoring on ECS Fargate with alert channels | [README](l1/uptime/README.md) | ## Modules diff --git a/modules/STANDARDS.md b/modules/STANDARDS.md new file mode 100644 index 0000000..e4a3c93 --- /dev/null +++ b/modules/STANDARDS.md @@ -0,0 +1,588 @@ +# ACDL Module Engineering Standards + +Standards for authoring and reviewing ACDL modules. These standards +govern the two module tiers — **L1 primitives** (single cloud resource +or small group of related resources) and **L2 modules** (compositions +that reference L1 primitives to deploy a complete stack) — and the +substrate adapter that compiles them to Terraform. They are written for +**platform engineers** and **AI agents** that author or review new +modules against the existing corpus (12 L1 primitives and 2 L2 modules +shipped in v1.8). + +A module that fails any section below is not ready to publish. + +## 1. Overview + +These standards codify the conventions already established by the +shipped modules (`s3`, `vpc`, `ecs-cluster`, `ecs-service`, `iam-role`, +`alb`, `ecr`, `cloudfront`, `waf`, `rds`, `kms-key`, `uptime`; the L2 +modules `static-assets` and `microservice`). They exist so that: + +- platform engineers can review a new module against a fixed checklist; +- AI agents authoring modules produce code that passes review without + iteration; and +- the substrate adapter (`adapters/terraform/adapter.py`) can compile a + module instance with no module-specific code in the adapter beyond the + three tables in §8. + +When this document and an existing module disagree, the existing module +is the authority for v1.x. A change to this document is a MINOR version +bump of the standards; a change that breaks shipped modules is a MAJOR +bump and requires a migration plan. + +## 2. L1 Primitive Standards + +An L1 primitive is a single cloud resource or a small group of related +resources (e.g. a VPC with subnets and a route table). It is declared by +an `interface.json` and realized by the substrate adapter; it does not +own Terraform code. + +### 2.1 Required files + +Every L1 primitive MUST contain, at minimum: + +| File | Purpose | +|------|---------| +| `interface.json` | Substrate-agnostic declaration: inputs, outputs, NFRs, optional multi-resource graph. | +| `instance.json` | A concrete instance used as the adapter regression baseline. | +| `README.md` | Plain-language documentation following `README-TEMPLATE.md` (see §7). | +| `examples/simple.yaml` | A minimal contract that uses the primitive with required inputs only. | +| `examples/complex.yaml` | A contract that exercises optional inputs, NFRs, and (if applicable) the multi-resource graph. | + +Directory layout: + +``` +modules/l1// + interface.json + instance.json + README.md + examples/ + simple.yaml + complex.yaml +``` + +### 2.2 interface.json schema + +`interface.json` MUST be a JSON object with the following required +fields: + +| Field | Type | Constraint | +|-------|------|------------| +| `name` | string | `^[a-z][a-z0-9-]*$`; MUST match the module folder name. | +| `version` | string | Semver (`^\d+\d+\.\d+$`); MUST match the registry entry semver. | +| `kind` | string | Literal `"l1"`. | +| `type` | string | Stack type in `aws::` format (see §2.7). | +| `description` | string | One or two sentences in plain language; no Terraform jargon. | +| `inputs` | object | Keyed by input name; each value is an input declaration (§2.3). MAY be empty. | +| `outputs` | object | Keyed by output name; each value is an output declaration (§2.4). MAY be empty. | +| `nfrs` | object | Keyed by NFR name; each value is an NFR declaration (§2.5). MUST include `deletion_protection` and `encryption_enabled`. | + +Optional fields for multi-resource primitives: + +| Field | Type | Constraint | +|-------|------|------------| +| `resources` | array | One entry per distinct cloud resource; see §2.6. | +| `intra_refs` | array | Internal wiring between resources; see §2.6. | + +A primitive that creates a single resource (e.g. `s3`, `iam-role`, +`rds`, `kms-key`) omits `resources` and `intra_refs`; its `type` field +is the single resource's stack type. A primitive that creates a small +group of related resources (e.g. `vpc`, `alb`, `cloudfront`) declares +`resources[]` with one entry per resource and `intra_refs[]` for the +internal wiring; its `type` field is the *primary* resource's stack +type. + +### 2.3 Input declaration + +Each entry in `inputs` is an object: + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `type` | string | yes | One of: `string`, `number`, `boolean`, `array`, `object`. | +| `description` | string | yes | Plain language; no Terraform jargon. | +| `required` | boolean | yes | `true` if the consumer MUST supply this input. | +| `default` | (any) | no | Present only when `required` is `false`. MUST match the declared `type`. | +| `enum` | array | no | Allowed values for `string`/`number` inputs (e.g. RDS `engine`). | + +`region` is a required `string` input on every primitive that creates a +regional resource. Global resources (e.g. CloudFront) still declare +`region` because the provider region is used for child resources (the +OAC in the `cloudfront` case). + +Every primitive that holds at-rest data MUST declare an optional +`kms_key_arn` input (`string`, `required: false`); see §4. + +### 2.4 Output declaration + +Each entry in `outputs` is an object: + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `type` | string | yes | `arn` for ARN outputs; `string` for all others. | +| `description` | string | yes | Plain language. | + +Use `arn` (not `string`) for any output that returns an AWS ARN — the +adapter and policy engine key off the `arn` type to apply ARN-scoped +rules. + +### 2.5 NFR declaration + +Each entry in `nfrs` is an object: + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `type` | string | yes | One of: `string`, `number`, `boolean`. | +| `description` | string | yes | Plain language. | +| `default` | (any) | yes | MUST match the declared `type`. NFRs always have a default. | + +Mandatory NFRs on every L1: + +| NFR | Type | Default | Notes | +|-----|------|---------|-------| +| `deletion_protection` | boolean | `true` | See §5. | +| `encryption_enabled` | boolean | `true` | See §4. | + +A primitive for which an NFR does not conceptually apply (e.g. an IAM +role has no at-rest data) still declares it with `default: true` and a +description noting the non-applicability, so the standards check and the +adapter emit logic stay uniform. The shipped `iam-role` primitive is the +reference for this case. + +Additional NFRs are encouraged where they carry operational meaning +(e.g. `s3.versioning`, `rds.backup_retention_period`, +`kms-key.enable_rotation`, `vpc.flow_logs_encrypted`). Name them in +lowercase snake_case. + +### 2.6 Multi-resource pattern + +A primitive that creates more than one cloud resource (e.g. `vpc` +creates `aws_vpc` + `aws_subnet` + `aws_route_table`; `alb` creates +`aws_lb` + `aws_lb_target_group` + `aws_lb_listener`; `cloudfront` +creates `aws_cloudfront_distribution` + +`aws_cloudfront_origin_access_control`) declares a `resources` array. + +Each `resources[]` entry: + +| Field | Type | Notes | +|-------|------|-------| +| `type` | string | The resource's stack type (`aws::`). | +| `description` | string | Plain language. | +| `inputs` | array | Names (strings) of inputs from the top-level `inputs` object that this resource consumes. | +| `outputs` | array | Names (strings) of outputs from the top-level `outputs` object that this resource produces. | + +The top-level `inputs`/`outputs` objects remain the single source of +truth; `resources[].inputs` and `resources[].outputs` are arrays of +*names* referencing those objects, not re-declarations. + +`intra_refs[]` wires outputs of one resource to inputs of another +within the same primitive. Each entry: + +| Field | Type | Notes | +|-------|------|-------| +| `from` | string | `.` — the producing side. | +| `to` | string | `.` — the consuming side. | + +Reference: `cloudfront/interface.json` declares an intra-ref from +`aws:cloudfront:distribution.oac_id` to +`aws:cloudfront:originaccesscontrol.oac_id`; `vpc/interface.json` +declares intra-refs from the subnet and route table to the VPC's +`vpc_id`. + +### 2.7 Naming and stack types + +- Module folder names and `interface.json` `name` values MUST match + `^[a-z][a-z0-9-]*$` (lowercase, hyphenated, leading letter). Examples: + `s3`, `ecs-cluster`, `kms-key`, `iam-role`, `uptime`. +- Input and output names are lowercase snake_case. +- Stack types follow `aws::`: + - `aws:s3:bucket` + - `aws:ec2:vpc`, `aws:ec2:subnet`, `aws:ec2:routetable` + - `aws:ecs:cluster`, `aws:ecs:task_definition`, `aws:ecs:service`, + `aws:ecs:uptime-service` + - `aws:iam:role` + - `aws:elbv2:loadbalancer`, `aws:elbv2:listener`, + `aws:elbv2:targetgroup` + - `aws:ecr:repository` + - `aws:cloudfront:distribution`, `aws:cloudfront:originaccesscontrol` + - `aws:wafv2:webacl` + - `aws:rds:instance` + - `aws:kms:key`, `aws:kms:alias` +- The substrate adapter's `TYPE_MAP` is the registry of stack types the + adapter can compile (see §8). A new stack type requires a `TYPE_MAP` + entry before the primitive can be deployed. + +## 3. L2 Module Standards + +An L2 module is a composition that references one or more L1 primitives +to deploy a complete stack (e.g. an ECS Fargate microservice, a static +asset site behind CloudFront + WAF). It is declared by a +`composition.json`; it does not own Terraform code and does not have an +`instance.json`. + +### 3.1 Required files + +| File | Purpose | +|------|---------| +| `composition.json` | The composition tree: children, wires, outputs, optional features. | +| `README.md` | Plain-language documentation following `README-TEMPLATE.md` (see §7). | +| `examples/simple.yaml` | A minimal contract that uses the module with required inputs only. | +| `examples/complex.yaml` | A contract that exercises optional inputs and feature flags. | + +Directory layout: + +``` +modules/l2// + composition.json + README.md + examples/ + simple.yaml + complex.yaml +``` + +There is no `instance.json` for an L2 module — the L2 is deployed by +resolving the composition tree to L1 instances at compile time, not by +loading a pre-baked instance. + +### 3.2 composition.json schema + +`composition.json` MUST be a JSON object with the following fields: + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `name` | string | yes | `^[a-z][a-z0-9-]*$`; matches the module folder name. | +| `version` | string | yes | Semver; matches the registry entry. | +| `kind` | string | yes | Literal `"l2"`. | +| `depth` | integer | yes | Literal `1` in v1 (see §3.5). | +| `description` | string | yes | Plain language. | +| `children` | array | yes | One entry per referenced L1 module (§3.3). | +| `wires` | array | yes | Wires from contract inputs / child outputs to child inputs / stack outputs (§3.4). | +| `outputs` | array | yes | Wires from child outputs to stack outputs (§3.4). | +| `features` | object | no | Feature flags propagated to children by the resolver (§3.6). | + +### 3.3 Children + +Each `children[]` entry: + +| Field | Type | Notes | +|-------|------|-------| +| `id` | string | The child id, unique within the composition. `^[a-z][a-z0-9-]*$`. The id is the local name used in wires (e.g. `vpc`, `cluster`, `kms`). | +| `module` | string | `@` referencing a registered L1 module. | + +Children MUST reference L1 modules registered in `registry.json` (see +§6). The referenced semver MUST exist in the registry. An L2 MUST NOT +reference another L2 (no L3 in v1; see §3.5). + +Reference: `microservice/composition.json` declares seven children +(`vpc`, `cluster`, `ecr`, `roles`, `alb`, `service`, `kms`), each +referencing an L1 at `@1.0.0`. + +### 3.4 Wire format + +A wire is a JSON object `{"from": "", "to": ""}` with an +optional `default` field for contract-input wires. + +Sources (the `from` side): + +| Source form | Meaning | +|-------------|---------| +| `contract.inputs.` | A value supplied by the consumer's contract YAML. | +| `.outputs.` | An output produced by a child L1 module. | + +Targets (the `to` side): + +| Target form | Meaning | +|-------------|---------| +| `.inputs.` | An input on a child L1 module. | +| `stack.outputs.` | A value the L2 exposes as a stack output. | + +Wires that source from `contract.inputs.` MAY carry a `default` +value used when the consumer omits the input. Reference: +`microservice/composition.json` wires `contract.inputs.bucket_name` to +`vpc.inputs.cidr` with `default: "10.0.0.0/16"` (a historical quirk +preserved for regression). + +The `outputs[]` array uses the same wire shape but its `to` is always +`stack.outputs.` and its `from` is always +`.outputs.`. + +### 3.5 Maximum depth + +`depth` is `1` for every L2 in v1. The composition tree is strictly L2 +→ L1: an L2 may reference only L1 primitives, never another L2. There +is no L3 in v1. The stack schema permits `depth` up to 5 for forward +compatibility, but the v1 resolver and adapter only handle depth 1. + +### 3.6 Feature flags + +An L2 MAY declare a `features` object. Two flags are defined in v1: + +| Flag | Type | Default | Effect | +|------|------|---------|--------| +| `deletion_protection` | boolean | `true` | When `true`, the resolver propagates `deletion_protection: true` to every child's NFRs. When `false`, children are deployed with `deletion_protection: false` (used by decommission; see §5). | +| `uptime_enabled` | boolean | `true` | When `true`, the uptime monitoring L1 is deployed after the L2 module in a separate terraform state. When `false`, the uptime deployment is skipped. | + +Feature flags are propagated to children by the resolver; the L2 +`composition.json` does not need to wire them explicitly as inputs. The +resolver reads `features` and injects the corresponding NFR/input on +each child. + +## 4. Encryption by Default + +Encryption is mandatory and on by default across the platform. + +1. Every L1 MUST declare an `encryption_enabled` NFR (boolean, default + `true`) in `interface.json`. See §2.5. +2. Every L1 that holds at-rest data (S3, RDS, ECR, ECS task + definition env, VPC flow logs, CloudWatch log groups) MUST declare an + optional `kms_key_arn` input (`string`, `required: false`). When + supplied, the adapter wires it to the resource's KMS encryption + argument. +3. L2 modules MUST wire a per-stack customer-managed KMS key to all + children that accept `kms_key_arn`. The KMS key is a `kms-key` child + of the L2 — one key per L2 deployment, no shared keys. Reference: + both `static-assets` and `microservice` declare a `kms` child + (`kms-key@1.0.0`) and wire `kms.outputs.kms_key_arn` to every child + that accepts a CMK. +4. For a standalone L1 deployment (an L1 used outside an L2), if the + consumer does not supply `kms_key_arn`, the adapter falls back to the + AWS-managed default key for that service and emits a warning to + stderr. The primitive is still encrypted; only the key manager + differs. +5. The `kms-key` primitive enables key rotation by default + (`enable_rotation` NFR, default `true`), and the adapter emits + `enable_key_rotation = true` on the `aws_kms_key` resource. + +A primitive that does not hold at-rest data (e.g. `iam-role`, +`ecs-cluster`, `alb`) still declares `encryption_enabled` for standards +uniformity (see §2.5) but does not declare `kms_key_arn`. + +## 5. Deletion Protection by Default + +Deletion protection is mandatory and on by default to prevent +accidental teardown of production infrastructure. + +1. Every L1 MUST declare a `deletion_protection` NFR (boolean, default + `true`) in `interface.json`. See §2.5. +2. When `deletion_protection` is `true`, the substrate adapter emits a + `lifecycle { prevent_destroy = true }` block on the corresponding + Terraform resource. A `terraform destroy` against a protected + resource fails with an error naming the resource. +3. L2 modules expose `features.deletion_protection` (default `true`). + The resolver propagates the flag to every child's NFRs (see §3.6). +4. **Decommission mode.** To tear down a stack that was deployed with + deletion protection, the consumer sets `inputs.deletion_protection: + false` on the contract (or `features.deletion_protection: false` on + an L2) and re-applies. The decommission transform + (`decommission_transform`) zeroes capacity counts (e.g. ECS desired + count to 0, RDS allocated storage to the minimum) so that the + subsequent `destroy` applies against a quiesced stack. The transform + is applied by the resolver before the adapter emits resources. + +## 6. Registry + +Every module — L1 and L2 — MUST be registered in +`modules/registry.json` at its semver. The registry is the source of +truth for what is published; the adapter and resolver refuse to compile +a module that is not registered. + +Registry entry shape: + +```json +{ + "": { + "": { + "interface": "modules///", + "published_at": "", + "deprecated": false + } + } +} +``` + +- `interface` is the path (relative to the repo root) to the module's + interface file — `interface.json` for an L1, `composition.json` for + an L2. +- `published_at` is an ISO 8601 timestamp. Use a full + `YYYY-MM-DDTHH:MM:SSZ` form; do not omit the seconds or the timezone + designator. +- `deprecated` is `false` for a live module. A MAJOR version bump does + not delete the old entry; it flips `deprecated` to `true` and starts a + 12-month deprecation window (see §7 Versioning). + +A new semver of an existing module is a new key under the module's +object; old semvers are retained. The registry is append-only for +published semvers — a published semver is never edited or deleted. + +## 7. README Standards + +Every module README MUST follow the structure of +`modules/README-TEMPLATE.md`. Required sections, in order: + +1. `# ` — title with the module + name and a one-line description. +2. `## Overview` — one or two sentences in plain language. +3. `## Resources` — a table of the Terraform resources the module + creates (L1) or the primitives it references (L2). +4. `## Inputs` — a table: `| Name | Type | Required | Default | Description |`. +5. `## Outputs` — a table: `| Name | Type | Description |`. +6. `## NFRs` — a table: `| Name | Type | Default | Description |`. + `deletion_protection` and `encryption_enabled` are mandatory NFRs + for every L1; they MUST appear in this table. +7. `## Usage` — a concrete snippet showing how a consumer references + the module in a contract. +8. `## Compliance extension points` — resources or behaviors that could + be added for the future compliance milestone (GDPR, SOX, SOC2, HIPAA, + DORA). Not implemented yet; listed so the redesign can plan for them. +9. `## Examples` — links to `examples/simple.yaml` and + `examples/complex.yaml` with a one-line description of each. +10. `## Versioning` — the module's semver policy: interface MAJOR, + behavior MINOR, lifecycle PATCH. MAJOR bumps require a new + `registry.json` entry (immutable publication); old entries enter a + 12-month deprecation window. + +An L2 README's `## Resources` section lists the referenced L1 children +rather than Terraform resources, and its `## Inputs`/`## Outputs` +sections reflect the contract inputs and stack outputs of the +composition. + +## 8. Adapter Extension Pattern + +The Terraform adapter (`adapters/terraform/adapter.py`) is a thin +translator. It owns no module content; it only maps stack types and +names to Terraform types and arguments via three tables and, for +complex resources, a specialized emit branch. + +### 8.1 The three tables + +| Table | Purpose | Keys | Values | +|-------|---------|------|--------| +| `TYPE_MAP` | Stack type → Terraform resource type. | Stack type string (`aws::`). | Terraform resource type (`aws_s3_bucket`, `aws_db_instance`, etc.). | +| `INPUT_MAP` | Stack input name → Terraform argument name, per stack type. Only non-identity mappings are listed; an input not present uses the stack name as the Terraform arg (identity). | Stack type. | Object mapping input name → Terraform arg name. | +| `OUTPUT_MAP` | Stack output name → Terraform attribute name, per stack type. Only non-identity mappings are listed. | Stack type. | Object mapping output name → Terraform attribute name. | + +Reference: `adapter.py:26` (`TYPE_MAP`), `adapter.py:51` (`INPUT_MAP`), +`adapter.py:75` (`OUTPUT_MAP`). + +### 8.2 Specialized `_emit_resource` branches + +Most resources emit with the generic loop in `_emit_resource` +(`adapter.py:156`): for each input, look up the Terraform arg in +`INPUT_MAP`, render the value, append `arg = value`. Resources with +nested HCL blocks need a specialized branch. The shipped examples: + +- `aws:ecs:service` emits a `load_balancer {}` block from the + `lb_target_group_arn` input. +- `aws:elbv2:loadbalancer` wraps `subnets` and `security_group` in list + brackets. +- `aws:cloudfront:distribution` emits nested `origin {}`, + `default_cache_behavior {}`, and + `server_side_encryption_configuration {}` blocks. +- `aws:wafv2:webacl` emits nested `rules {}` blocks. +- `aws:ecs:task_definition` emits a `container_definitions` jsonencode + block from `image`/`port`/`env`. + +A specialized branch lives inside `_emit_resource` and is keyed on the +stack type. It reads the input value, renders the nested block, and +appends the lines to `body`. + +### 8.3 Adding a new L1 to the adapter + +When a new L1 primitive is added: + +1. Add one entry to `TYPE_MAP` for each stack type the primitive + declares (single resource → one entry; multi-resource → one entry + per resource in `resources[]`). +2. Add one entry to `INPUT_MAP` for each stack type, listing only the + inputs whose Terraform arg name differs from the stack input name + (identity mappings are omitted). +3. Add one entry to `OUTPUT_MAP` for each stack type, listing only the + outputs whose Terraform attribute name differs from the stack output + name. +4. If any resource requires nested HCL blocks, add a specialized branch + in `_emit_resource` keyed on that stack type. + +If steps 1–3 are done and no specialized branch is needed, the +primitive deploys with no further adapter changes. The L1 content and +the contract YAML do not change when the adapter grows. + +## 9. Code Review Checklist + +Use this checklist when reviewing a new module (L1 or L2). Every box +must be checked before the module is registered and published. + +### 9.1 Files and structure + +- [ ] All required files present: + - L1: `interface.json`, `instance.json`, `README.md`, + `examples/simple.yaml`, `examples/complex.yaml`. + - L2: `composition.json`, `README.md`, `examples/simple.yaml`, + `examples/complex.yaml` (no `instance.json`). +- [ ] `interface.json` (L1) / `composition.json` (L2) validates against + `schemas/stack.schema.json`. +- [ ] `examples/simple.yaml` and `examples/complex.yaml` validate + against `schemas/contract.schema.json`. +- [ ] Module registered in `modules/registry.json` at its semver with a + full ISO 8601 `published_at` and `deprecated: false`. + +### 9.2 Interface (L1) + +- [ ] `name` matches the folder name and `^[a-z][a-z0-9-]*$`. +- [ ] `version` is semver and matches the registry entry. +- [ ] `kind` is `"l1"`. +- [ ] `type` follows `aws::`. +- [ ] Every input has `type`, `description`, `required`; optional inputs + carry a `default` of the correct type; `enum` present where the value + set is constrained. +- [ ] Every output has `type` (`arn` for ARNs, `string` otherwise) and + `description`. +- [ ] `nfrs` includes `deletion_protection` (boolean, default `true`) + and `encryption_enabled` (boolean, default `true`). +- [ ] `kms_key_arn` input present if the primitive holds at-rest data. +- [ ] Multi-resource primitives declare `resources[]` (with `inputs`/ + `outputs` as arrays of names) and `intra_refs[]` with `{from, to}`. + +### 9.3 Composition (L2) + +- [ ] `kind` is `"l2"` and `depth` is `1`. +- [ ] Every `children[]` entry is `{id, module}` with `module` in + `@` form referencing a registered L1. +- [ ] No child references an L2 (no L3 in v1). +- [ ] `wires[]` use the `contract.inputs.` / + `.outputs.` → `.inputs.` / + `stack.outputs.` forms. +- [ ] `outputs[]` use `.outputs.` → + `stack.outputs.`. +- [ ] A `kms` child (`kms-key@`) is present and its + `kms_key_arn` output is wired to every child that accepts a CMK. +- [ ] `features` (if present) only uses defined flags + (`deletion_protection`, `uptime_enabled`). + +### 9.4 Adapter + +- [ ] `TYPE_MAP` has an entry for every stack type the new primitive + declares. +- [ ] `INPUT_MAP` and `OUTPUT_MAP` have entries for every stack type, + listing only non-identity mappings. +- [ ] A specialized `_emit_resource` branch is added for any resource + that needs nested HCL blocks. +- [ ] The new primitive's `instance.json` round-trips through the + adapter without error (regression baseline). + +### 9.5 README and docs + +- [ ] README follows `README-TEMPLATE.md` with all required sections in + order (§7). +- [ ] `## NFRs` table lists `deletion_protection` and + `encryption_enabled` for an L1. +- [ ] `## Compliance extension points` lists at least one plausible + future extension. + +### 9.6 Tests + +- [ ] A test is added for the new primitive covering adapter emission + (the Terraform output for `instance.json` matches the expected + fixture) and interface validation (`interface.json` validates against + `stack.schema.json`). +- [ ] For an L2, a test is added that the composition resolves to the + expected set of L1 instances and that the adapter emits a root module + calling the L1 modules. \ No newline at end of file diff --git a/tests/test_module_standards.py b/tests/test_module_standards.py new file mode 100644 index 0000000..e5ebd91 --- /dev/null +++ b/tests/test_module_standards.py @@ -0,0 +1,122 @@ +"""Automated enforcement of module engineering standards (REQ-95, REQ-96).""" + +import json +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +ROOT = Path(__file__).resolve().parent.parent + + +class TestModuleStandards: + """REQ-95/96: automated standards enforcement for all modules.""" + + @pytest.fixture + def registry(self): + return json.load(open(ROOT / "modules" / "registry.json")) + + def test_all_l1_have_required_files(self, registry): + for name, entry in registry.items(): + iface_path = entry["1.0.0"]["interface"] + if not iface_path.startswith("modules/l1/"): + continue + l1_dir = ROOT / "modules" / "l1" / name + assert (l1_dir / "interface.json").is_file(), f"{name}: interface.json missing" + assert (l1_dir / "instance.json").is_file(), f"{name}: instance.json missing" + assert (l1_dir / "README.md").is_file(), f"{name}: README.md missing" + assert (l1_dir / "examples" / "simple.yaml").is_file(), f"{name}: examples/simple.yaml missing" + assert (l1_dir / "examples" / "complex.yaml").is_file(), f"{name}: examples/complex.yaml missing" + + def test_all_l2_have_required_files(self, registry): + for name, entry in registry.items(): + iface_path = entry["1.0.0"]["interface"] + if not iface_path.startswith("modules/l2/"): + continue + l2_dir = ROOT / "modules" / "l2" / name + assert (l2_dir / "composition.json").is_file(), f"{name}: composition.json missing" + assert (l2_dir / "README.md").is_file(), f"{name}: README.md missing" + assert (l2_dir / "examples" / "simple.yaml").is_file(), f"{name}: examples/simple.yaml missing" + assert (l2_dir / "examples" / "complex.yaml").is_file(), f"{name}: examples/complex.yaml missing" + + def test_all_l1_have_deletion_protection_nfr(self, registry): + for name, entry in registry.items(): + iface_path = entry["1.0.0"]["interface"] + if not iface_path.startswith("modules/l1/"): + continue + iface = json.load(open(ROOT / iface_path)) + assert "deletion_protection" in iface.get("nfrs", {}), \ + f"{name}: deletion_protection NFR missing" + + def test_all_l1_have_encryption_enabled_nfr(self, registry): + for name, entry in registry.items(): + iface_path = entry["1.0.0"]["interface"] + if not iface_path.startswith("modules/l1/"): + continue + iface = json.load(open(ROOT / iface_path)) + assert "encryption_enabled" in iface.get("nfrs", {}), \ + f"{name}: encryption_enabled NFR missing" + + def test_all_l1_deletion_protection_defaults_true(self, registry): + for name, entry in registry.items(): + iface_path = entry["1.0.0"]["interface"] + if not iface_path.startswith("modules/l1/"): + continue + iface = json.load(open(ROOT / iface_path)) + dp = iface.get("nfrs", {}).get("deletion_protection", {}) + assert dp.get("default") is True, \ + f"{name}: deletion_protection default must be true" + + def test_all_l1_encryption_enabled_defaults_true(self, registry): + for name, entry in registry.items(): + iface_path = entry["1.0.0"]["interface"] + if not iface_path.startswith("modules/l1/"): + continue + iface = json.load(open(ROOT / iface_path)) + ee = iface.get("nfrs", {}).get("encryption_enabled", {}) + assert ee.get("default") is True, \ + f"{name}: encryption_enabled default must be true" + + def test_all_modules_registered(self, registry): + l1_dirs = [d.name for d in (ROOT / "modules" / "l1").iterdir() if d.is_dir() and not d.name.startswith(".")] + l2_dirs = [d.name for d in (ROOT / "modules" / "l2").iterdir() if d.is_dir() and not d.name.startswith(".")] + for name in l1_dirs: + assert name in registry, f"modules/l1/{name}/ not in registry.json" + for name in l2_dirs: + assert name in registry, f"modules/l2/{name}/ not in registry.json" + + def test_all_l1_readmes_have_nfrs_section(self, registry): + """Check NFRs section for new v1.8 primitives (kms-key, uptime). + Existing pre-v1.8 READMEs are grandfathered — the interface.json + NFR check is the binding enforcement.""" + new_primitives = ["kms-key", "uptime"] + for name in new_primitives: + if name not in registry: + continue + readme = open(ROOT / "modules" / "l1" / name / "README.md").read() + assert "## NFRs" in readme or "## NFR" in readme, \ + f"{name}: README.md must have a NFRs section" + + def test_standards_md_exists(self): + assert (ROOT / "modules" / "STANDARDS.md").is_file(), "modules/STANDARDS.md must exist" + + def test_standards_md_has_required_sections(self): + content = open(ROOT / "modules" / "STANDARDS.md").read() + assert "L1 Primitive Standards" in content + assert "L2 Module Standards" in content + assert "Encryption by Default" in content + assert "Deletion Protection by Default" in content + assert "Code Review Checklist" in content + + def test_catalog_index_has_all_primitives(self, registry): + content = open(ROOT / "modules" / "README.md").read() + for name in registry: + if registry[name]["1.0.0"]["interface"].startswith("modules/l1/"): + assert name in content, f"modules/README.md catalog index missing primitive: {name}" + + def test_template_has_nfrs_section(self): + content = open(ROOT / "modules" / "README-TEMPLATE.md").read() + assert "## NFRs" in content or "## NFR" in content, "README-TEMPLATE.md must have NFRs section" \ No newline at end of file