# Pre-Commit Review Checklist

Review every generated file against this checklist before presenting to the user.

---

## OpenTofu

### Provider and state

- [ ] `required_version >= 1.0` set
- [ ] All providers pinned with `~>` constraints
- [ ] `backend "s3" {}` is empty (config via `-backend-config`)
- [ ] Backend HCL files have `encrypt = true` and `dynamodb_table`
- [ ] `.terraform.lock.hcl` will be committed (not in `.gitignore`)
- [ ] `.gitignore` excludes `.terraform/`, `*.tfstate`, `*.tfstate.backup`

### Variables

- [ ] Every variable has `description` and explicit `type`
- [ ] Secret variables have `sensitive = true` and NO default value
- [ ] Non-secret variables with sensible defaults have `default`
- [ ] Variables are grouped by section (Networking, Compute, Database, Secrets, Tags)

### Naming and tagging

- [ ] `locals` block defines `name_prefix` and `common_tags`
- [ ] Every resource uses `merge(local.common_tags, { Name = "..." })`
- [ ] `ManagedBy = "opentofu"` is in common_tags

### Networking

- [ ] VPC has `enable_dns_hostnames = true` and `enable_dns_support = true`
- [ ] Public and private subnets are in at least 2 AZs (required for RDS subnet groups)
- [ ] Internet gateway exists and is attached to public route table
- [ ] Private route table exists with NO routes (explicitly empty, not relying on default)
- [ ] All subnets are explicitly associated with a route table
- [ ] CIDR calculation uses `cidrsubnet()` for consistency

### Security groups

- [ ] Security groups use `name_prefix` (not `name`)
- [ ] Security groups have `create_before_destroy` lifecycle
- [ ] Every SG and every rule has a `description`
- [ ] Ingress rules use VPN CIDRs (not `0.0.0.0/0`) unless intentionally public
- [ ] RDS SG only allows ingress from EC2 SG (security_groups reference, not CIDR)

### Compute (EC2)

- [ ] IMDSv2 is required: `http_tokens = "required"`
- [ ] Hop limit is 2: `http_put_response_hop_limit = 2` (for Docker)
- [ ] EBS is encrypted: `encrypted = true`
- [ ] Volume type is `gp3` (not `gp2`)
- [ ] User data installs Docker and Docker Compose plugin
- [ ] `user_data_replace_on_change = true` is set
- [ ] Instance has IAM instance profile attached

### Database (RDS)

- [ ] `publicly_accessible = false`
- [ ] `storage_encrypted = true`
- [ ] `copy_tags_to_snapshot = true`
- [ ] `performance_insights_enabled = true`
- [ ] `auto_minor_version_upgrade = true`
- [ ] Password comes from `random_password` (not variable)
- [ ] DB subnet group uses private subnets only
- [ ] `skip_final_snapshot` and `deletion_protection` have sandbox/prod comments

### S3

- [ ] Public access block with all four flags set to `true`
- [ ] Server-side encryption configured (SSE-KMS preferred)
- [ ] `bucket_key_enabled = true`
- [ ] Lifecycle rule for expiring old objects (if applicable)

### Secrets

- [ ] Auto-generated secrets use `random_password` or `random_id`
- [ ] `random_password.override_special` avoids connection-string-breaking chars
- [ ] Secrets Manager secret uses `name_prefix` (not `name`)
- [ ] Secrets Manager `recovery_window_in_days = 0` for sandbox, `7+` for prod
- [ ] Secrets Manager version stores full JSON with all connection details
- [ ] SSM parameters for user-provided secrets have `ignore_changes = [value]`
- [ ] SSM parameter naming follows `/<project>-<env>/<secret-name>` convention

### IAM

- [ ] IAM role has minimal permissions
- [ ] S3 policy scoped to specific bucket ARN
- [ ] Secrets policy scoped to specific secret/parameter ARNs (not `*`)
- [ ] SES policy scoped to specific identity (if applicable)
- [ ] Instance profile is attached to EC2

### Outputs

- [ ] Every value Ansible needs is an output
- [ ] `ec2_public_ip` output exists (for dynamic inventory)
- [ ] RDS host/port outputs exist (if RDS is used)
- [ ] S3 bucket name/region outputs exist (if S3 is used)
- [ ] Secret ARNs and SSM prefix are outputs
- [ ] `aws_region` is an output (Ansible needs it for API calls)

---

## Ansible

### Configuration

- [ ] `ansible.cfg` sets `inventory = inventory.py`
- [ ] `stdout_callback = yaml` is set
- [ ] `IdentitiesOnly=yes` in ssh_args
- [ ] `gathering = smart`

### Dynamic inventory

- [ ] `inventory.py` is executable (`chmod +x`)
- [ ] Script reads from sibling `../opentofu` directory
- [ ] All tofu outputs are mapped to host variables
- [ ] Group name matches the app name
- [ ] Empty/error case returns valid empty inventory JSON

### Secret handling

- [ ] Every secret-fetching task has `no_log: true`
- [ ] Every `set_fact` for secrets has `no_log: true`
- [ ] Every template task rendering secrets has `no_log: true`
- [ ] AWS API calls use `delegate_to: localhost` and `become: false`
- [ ] `profile: "{{ aws_profile | default(omit) }}"` passes through
- [ ] Secrets Manager secrets are parsed with `| from_json`
- [ ] SSM parameters use `with_decryption: true`

### Tasks

- [ ] First task waits for cloud-init: `cloud-init status --wait`
- [ ] Docker is verified running before any compose operations
- [ ] Deploy directory is created with restricted permissions (`0750`)
- [ ] Template files have restricted permissions (`0640` for config, `0600` for .env)
- [ ] Docker images are pulled before `docker compose up`
- [ ] Health check waits for app readiness after start

### Handlers

- [ ] Handlers restart individual services (not whole stack)
- [ ] Handlers use `--force-recreate` to pick up changes
- [ ] Template tasks notify appropriate handlers

### Dependencies

- [ ] No Ansible Galaxy collections required (secret fetching uses AWS CLI)
- [ ] AWS CLI is assumed installed on the control machine

---

## Docker Compose

- [ ] All images use pinned versions (no `:latest`)
- [ ] All services have `restart: unless-stopped`
- [ ] All services have memory limits via `deploy.resources.limits.memory`
- [ ] App service has a `healthcheck` with appropriate start_period
- [ ] App services use `expose` (internal only), not `ports`
- [ ] Only Caddy binds to host ports 80 and 443
- [ ] Caddy volume mounts Caddyfile as `:ro`
- [ ] Named volumes for persistent data
- [ ] Bridge network for inter-container communication
- [ ] `.env` file is referenced via `env_file` (not inline `environment`)

---

## Caddyfile

- [ ] VPN-only: plain HTTP on `:80` (no TLS needed behind VPN)
- [ ] Public: uses domain name (Caddy auto-provisions TLS via ACME)
- [ ] `reverse_proxy` points to service name and internal port

---

## Environment file (.env.j2)

- [ ] All values are quoted (prevents special char issues)
- [ ] DATABASE_URL is constructed from individual components
- [ ] No secrets are hardcoded — all come from Ansible variables
- [ ] File permission is `0600` on the target host
