1 Commits

Author SHA1 Message Date
CIAgent 756100ab17 docs(P04): complete l2-patterns-bootstrap-platform phase
---ci---
project: nova-platform
phase: 4
milestone: v1.0
status: complete
---/ci---

P4 complete: L2 patterns (D-038), bootstrap (D-022/026), platform
(D-023), ci-vpc (D-024), onboarding (D-025), microservice root.
68 tests pass. REQ-12,14,15,16,17,18,19,22 covered.
2026-08-24 17:47:24 +00:00
20 changed files with 1548 additions and 6 deletions
+7 -6
View File
@@ -1,20 +1,21 @@
{
"phase": 3,
"phase": 4,
"stage": "verify",
"milestone": "v1.0",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-08-20T19:45:00Z",
"updated_at": "2026-08-20T20:15:00Z",
"project": "nova-platform",
"milestone_branch": "milestone/v1.0-nova-platform",
"phase_branch": "phase/03-l1-primitives-registry",
"phase_branch": "phase/04-l2-patterns-bootstrap-platform",
"phase_0_ship": {"tag": "v0.1.0", "local_only": true},
"phase_1_ship": {"tag": "v0.1.1", "local_only": true},
"phase_2_ship": {"tag": "v0.1.2", "local_only": true},
"phase_3_verify": {
"phase_3_ship": {"tag": "v0.1.3", "local_only": true},
"phase_4_verify": {
"tests_pass": true,
"tests_count": 68,
"reqs_covered": ["REQ-10", "REQ-11", "REQ-13", "REQ-34"]
"reqs_covered": ["REQ-12", "REQ-14", "REQ-15", "REQ-16", "REQ-17", "REQ-18", "REQ-19", "REQ-22"]
},
"next_phase": "phase/04-l2-patterns-bootstrap-platform"
"next_phase": "phase/05-shell-reproducibility-tests-docs"
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "microservice",
"version": "1.0.0",
"kind": "l2",
"description": "ECS Fargate microservice pattern (opaque L2 per D-012). Composes six L1 primitives internally via terraform/main.tf module blocks: vpc + ecs-cluster + ecs-service + iam-role + ecr + alb (D-038). The L2 interface exposes a simplified, engine-agnostic input/output surface; the children/wires are NOT present in the interface (they live in the Terraform). The stack has a single resource entry for this module — the resolver does NOT expand children.",
"inputs": {
"service_name": {
"type": "string",
"description": "Name of the ECS service (also used for the cluster, ECR repo, IAM role, ALB, and task definition family).",
"required": true
},
"desired_count": {
"type": "integer",
"default": 1,
"description": "Number of ECS Fargate tasks to run."
},
"container_image": {
"type": "string",
"description": "Container image to deploy (e.g. <account>.dkr.ecr.<region>.amazonaws.com/<repo>:latest). Passed into the ECS task definition container definitions.",
"required": true
},
"container_port": {
"type": "integer",
"default": 80,
"description": "Container port the service listens on (used in the task definition port mapping and the ALB target group)."
}
},
"outputs": {
"service_arn": {
"type": "arn",
"description": "The ARN of the deployed ECS service."
},
"lb_dns_name": {
"type": "string",
"description": "The DNS name of the fronting Application Load Balancer."
}
}
}
+161
View File
@@ -0,0 +1,161 @@
# Nova L2 pattern: microservice (opaque composition, D-012/D-038).
#
# Composes six L1 primitives internally via module blocks:
# vpc + ecs-cluster + ecs-service + iam-role + ecr + alb (D-038).
#
# The L2 interface (interface.json) exposes a simplified, engine-agnostic
# input/output surface (service_name, desired_count, container_image,
# container_port → service_arn, lb_dns_name). The children/wires are NOT
# in the interface — they live here in terraform/main.tf.
#
# The L1 primitives are minimal/atomic (D-014); some glue that the L1s do
# not expose (the ECS task definition container JSON, the ECS service
# network + load-balancer wiring, the ALB security group) is added here
# in the L2 — this is the "L2 composes L1 internally" model (D-012).
terraform {
required_version = ">= 1.9, < 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
data "aws_availability_zones" "available" {
state = "available"
}
# ---------------------------------------------------------------------------
# L1 composition (D-038): vpc + ecs-cluster + ecr + iam-role + alb + ecs-service.
# ---------------------------------------------------------------------------
# 1. VPC — the network the microservice runs in. (Per D-038 the microservice
# includes its own VPC as one of the 6 L1s. When platform_subnet_ids /
# platform_security_group_id are supplied by the consumer root, the L1
# vpc module is disabled and the platform VPC outputs are used directly
# — no per-contract VPC is created.)
module "vpc" {
source = "../../l1/vpc/terraform"
cidr = "10.0.0.0/16"
azs = data.aws_availability_zones.available.names
enabled = var.platform_subnet_ids == null
}
# 2. ECS cluster — the scheduling boundary.
module "cluster" {
source = "../../l1/ecs-cluster/terraform"
cluster_name = "${var.service_name}-cluster"
}
# 3. ECR repository — holds the container image.
module "ecr" {
source = "../../l1/ecr/terraform"
repository_name = var.service_name
}
# 4. IAM role — the ECS task execution + task role. The L1 iam-role primitive
# takes a policy_document (assume-role trust); managed policies are
# attached by the L2 glue below.
module "role" {
source = "../../l1/iam-role/terraform"
role_name = "${var.service_name}-task-role"
policy_document = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
}]
})
}
# 5. ALB — the public-facing load balancer fronting the ECS service. The L1
# alb primitive takes subnet_ids + target_group_port; the L2 wires the
# platform VPC subnets (or the per-contract VPC subnets) into it.
module "alb" {
source = "../../l1/alb/terraform"
lb_name = "${var.service_name}-alb"
subnet_ids = var.platform_subnet_ids == null ? module.vpc.subnet_ids : var.platform_subnet_ids
target_group_port = var.container_port
}
# 6. ECS service — runs the task definition on the cluster. The L1 ecs-service
# primitive accepts a task_definition ARN; the L2 registers the full
# task definition (container JSON + role + network mode) via the glue
# resource aws_ecs_task_definition.this below and passes its ARN here.
module "service" {
source = "../../l1/ecs-service/terraform"
service_name = var.service_name
cluster_arn = module.cluster.cluster_arn
task_definition = aws_ecs_task_definition.this.arn
desired_count = var.desired_count
}
# ---------------------------------------------------------------------------
# L2 glue — resources the L1 primitives do not expose.
# ---------------------------------------------------------------------------
# ECS task definition with container definitions JSON. The L1 ecs-service
# accepts a task_definition string; here we register the full definition
# (CPU/memory + container port mapping + the ECR image + the task role).
resource "aws_ecs_task_definition" "this" {
family = var.service_name
cpu = "256"
memory = "512"
execution_role_arn = module.role.role_arn
task_role_arn = module.role.role_arn
network_mode = "awsvpc"
container_definitions = jsonencode([
{
name = var.service_name
image = var.container_image
essential = true
portMappings = [
{
containerPort = var.container_port
protocol = "tcp"
}
]
}
])
}
# Attach the AmazonECSTaskExecutionRolePolicy managed policy to the task
# role created by the L1 iam-role primitive (the L1 does not attach
# managed policies — it only creates the role + trust policy).
resource "aws_iam_role_policy_attachment" "task_exec" {
role = module.role.role_name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
# Security group for the ALB (ingress on the container port from the
# internet; egress to the VPC). The L1 alb primitive does not manage its
# own security group (kept minimal per D-014); the L2 owns it here.
resource "aws_security_group" "alb" {
count = var.platform_security_group_id == null ? 1 : 0
name = "${var.service_name}-alb-sg"
description = "Security group for the ${var.service_name} ALB (L2 glue)."
vpc_id = var.platform_subnet_ids == null ? module.vpc.vpc_id : null
ingress {
from_port = var.container_port
to_port = var.container_port
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
@@ -0,0 +1,15 @@
# L2 microservice outputs — match the interface.json outputs (D-013).
#
# The L2 interface exposes a simplified, engine-agnostic output surface:
# service_arn + lb_dns_name. Internal L1 outputs are NOT re-exported (the
# L2 is opaque per D-012).
output "service_arn" {
description = "The ARN of the deployed ECS service."
value = module.service.service_arn
}
output "lb_dns_name" {
description = "The DNS name of the fronting Application Load Balancer."
value = module.alb.dns_name
}
@@ -0,0 +1,44 @@
# L2 microservice variables — match the interface.json inputs (D-013).
#
# The L2 interface exposes a simplified, engine-agnostic input surface.
# The platform-wiring variables (platform_subnet_ids,
# platform_security_group_id) are L2-internal — they let the consumer root
# (terraform/microservice/main.tf) wire the L2 into the shared platform VPC
# via terraform_remote_state. They are NOT part of the L2 interface (the
# interface stays minimal per D-013).
variable "service_name" {
description = "Name of the ECS service (also used for the cluster, ECR repo, IAM role, ALB, and task definition family)."
type = string
}
variable "desired_count" {
description = "Number of ECS Fargate tasks to run."
type = number
default = 1
}
variable "container_image" {
description = "Container image to deploy (e.g. <account>.dkr.ecr.<region>.amazonaws.com/<repo>:latest). Passed into the ECS task definition container definitions."
type = string
}
variable "container_port" {
description = "Container port the service listens on (used in the task definition port mapping and the ALB target group)."
type = number
default = 80
}
# --- L2-internal platform-wiring variables (NOT in interface.json) ---
variable "platform_subnet_ids" {
description = "Platform VPC subnet IDs (from terraform_remote_state.platform). When set, the L1 vpc module is disabled and these subnets are used directly (no per-contract VPC). When null, the L2 creates its own VPC."
type = list(string)
default = null
}
variable "platform_security_group_id" {
description = "Platform VPC ECS security group ID (from terraform_remote_state.platform). When set, the L2 reuses it and skips creating its own ALB SG."
type = string
default = null
}
@@ -0,0 +1,10 @@
terraform {
required_version = ">= 1.9, < 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "static-assets",
"version": "1.0.0",
"kind": "l2",
"description": "Static asset site pattern (opaque L2 per D-012). Composes three L1 primitives internally via terraform/main.tf module blocks: s3 + cloudfront + kms-key (D-038 — drops waf from the reference). The L2 interface exposes a simplified, engine-agnostic input/output surface; the children/wires are NOT present in the interface (they live in the Terraform). The stack has a single resource entry for this module — the resolver does NOT expand children. Per D-035, index_document is an L2 input (passthrough to the S3 website config in L2 terraform); the L1 s3 primitive does not gain it.",
"inputs": {
"bucket_name": {
"type": "string",
"description": "Globally-unique S3 bucket name for the static assets.",
"required": true
},
"index_document": {
"type": "string",
"default": "index.html",
"description": "S3 website index document (L2 input per D-035; passed through to the S3 website configuration in L2 terraform)."
}
},
"outputs": {
"bucket_website_url": {
"type": "string",
"description": "The S3 bucket website endpoint URL (origin for CloudFront)."
},
"cloudfront_domain": {
"type": "string",
"description": "The CloudFront distribution domain name (the public edge URL)."
}
}
}
@@ -0,0 +1,93 @@
# Nova L2 pattern: static-assets (opaque composition, D-012/D-038).
#
# Composes three L1 primitives internally via module blocks:
# s3 + cloudfront + kms-key (D-038 — drops waf from the reference).
#
# The L2 interface (interface.json) exposes a simplified, engine-agnostic
# input/output surface (bucket_name, index_document → bucket_website_url,
# cloudfront_domain). The children/wires are NOT in the interface — they
# live here in terraform/main.tf.
#
# Per D-035, index_document is an L2 input (passthrough to the S3 website
# config in L2 terraform); the L1 s3 primitive does NOT gain it (stays
# ref interface). The L2 adds the S3 website configuration + bucket policy
# glue that the L1 s3 primitive does not expose.
terraform {
required_version = ">= 1.9, < 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# ---------------------------------------------------------------------------
# L1 composition (D-038): kms-key + s3 + cloudfront.
# ---------------------------------------------------------------------------
# 1. KMS key — customer-managed key for S3 SSE-KMS.
module "kms" {
source = "../../l1/kms-key/terraform"
key_name = "${var.bucket_name}-key"
}
# 2. S3 bucket — holds the static assets. The L1 s3 primitive takes
# bucket_name + kms_key_arn; the L2 wires the KMS key ARN from the kms
# L1 module into it.
module "s3" {
source = "../../l1/s3/terraform"
bucket_name = var.bucket_name
kms_key_arn = module.kms.key_arn
}
# 3. CloudFront distribution — the CDN edge in front of the S3 origin.
# The L1 cloudfront primitive takes distribution_name + origin_domain;
# the L2 wires the S3 bucket website endpoint as the origin domain.
module "cloudfront" {
source = "../../l1/cloudfront/terraform"
distribution_name = var.bucket_name
origin_domain = aws_s3_bucket_website_configuration.this[0].website_endpoint
}
# ---------------------------------------------------------------------------
# L2 glue — resources the L1 primitives do not expose.
# ---------------------------------------------------------------------------
# S3 website configuration — per D-035, index_document is an L2 input
# passed through to the S3 website config here. The L1 s3 primitive does
# NOT create a website config (kept minimal per D-014); the L2 owns it.
resource "aws_s3_bucket_website_configuration" "this" {
count = var.enabled ? 1 : 0
bucket = module.s3.bucket_name
index_document {
suffix = var.index_document
}
}
# S3 bucket public-read policy — allows CloudFront (and the public, for a
# static site) to GET objects. The L1 s3 primitive does not attach a bucket
# policy (kept minimal per D-014); the L2 owns it for the static-site use
# case.
resource "aws_s3_bucket_policy" "this" {
count = var.enabled ? 1 : 0
bucket = module.s3.bucket_name
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "PublicReadGetObject"
Effect = "Allow"
Principal = "*"
Action = ["s3:GetObject"]
Resource = ["${module.s3.bucket_arn}/*"]
}
]
})
}
@@ -0,0 +1,15 @@
# L2 static-assets outputs — match the interface.json outputs (D-013).
#
# The L2 interface exposes a simplified, engine-agnostic output surface:
# bucket_website_url + cloudfront_domain. Internal L1 outputs are NOT
# re-exported (the L2 is opaque per D-012).
output "bucket_website_url" {
description = "The S3 bucket website endpoint URL (origin for CloudFront)."
value = var.enabled ? "https://${aws_s3_bucket_website_configuration.this[0].website_endpoint}" : null
}
output "cloudfront_domain" {
description = "The CloudFront distribution domain name (the public edge URL)."
value = module.cloudfront.domain_name
}
@@ -0,0 +1,22 @@
# L2 static-assets variables — match the interface.json inputs (D-013/D-035).
#
# The L2 interface exposes a simplified, engine-agnostic input surface.
# index_document is an L2 input (D-035 — passthrough to the S3 website
# config in L2 terraform; the L1 s3 primitive does NOT gain it).
variable "bucket_name" {
description = "Globally-unique S3 bucket name for the static assets."
type = string
}
variable "index_document" {
description = "S3 website index document (L2 input per D-035; passed through to the S3 website configuration in L2 terraform)."
type = string
default = "index.html"
}
variable "enabled" {
description = "Feature flag: enable/disable this L2 pattern. Set to false to skip resource creation."
type = bool
default = true
}
@@ -0,0 +1,10 @@
terraform {
required_version = ">= 1.9, < 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Nova Platform — Rotate the spike runner key into .env.secrets.
#
# Requires NOVA_BOOTSTRAP_AWS_* (or NOVA_AWS_* fallback) for the bootstrap
# IAM actions. Writes NOVA_AWS_ACCESS_KEY_ID + NOVA_AWS_SECRET_ACCESS_KEY
# to .ciagent/.env.secrets (chmod 600, gitignored). Never echoes the secret.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SECRETS="$ROOT/.ciagent/.env.secrets"
if [ -z "${NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID:-${NOVA_AWS_ACCESS_KEY_ID:-${AWS_ACCESS_KEY_ID:-}}}" ]; then
echo "FAIL: set NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID + NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY" >&2
exit 1
fi
OUT=$(python3 "$ROOT/terraform/bootstrap/create_iam_user.py")
KEY=$(echo "$OUT" | grep '^NOVA_AWS_ACCESS_KEY_ID=' | cut -d= -f2)
SECRET=$(echo "$OUT" | grep '^NOVA_AWS_SECRET_ACCESS_KEY=' | cut -d= -f2)
if [ -z "$KEY" ] || [ -z "$SECRET" ]; then
echo "FAIL: no new key returned (existing key may be active)" >&2
exit 1
fi
touch "$SECRETS"
chmod 600 "$SECRETS"
grep -v '^NOVA_AWS_ACCESS_KEY_ID=' "$SECRETS" 2>/dev/null | grep -v '^NOVA_AWS_SECRET_ACCESS_KEY=' > "$SECRETS.tmp" || true
echo "NOVA_AWS_ACCESS_KEY_ID=$KEY" >> "$SECRETS.tmp"
echo "NOVA_AWS_SECRET_ACCESS_KEY=$SECRET" >> "$SECRETS.tmp"
mv "$SECRETS.tmp" "$SECRETS"
chmod 600 "$SECRETS"
echo "=== KEY ROTATED ==="
echo " written to: $SECRETS (mode 600)"
+128
View File
@@ -0,0 +1,128 @@
# Nova Bootstrap Runbook
Phase 4 bootstraps the AWS state backend + the spike runner IAM user for
the nova-platform. Two scripts create the infrastructure exactly once;
after that, the rotated spike-runner key is used for all platform + CI
operations.
> **Spike scope (D-025):** onboarding uses a cross-account IAM *role*
> (not OIDC). The consumer's CI runner assumes the deploy role via
> `sts assume-role` using the platform runner user's static credentials.
> Real OIDC federation is the production path, OOS for nova v1.0.
## State backend (D-022)
`create_state_backend.py` creates (idempotent):
- **S3 bucket** `nova-tfstate-<account>-<region>` (versioned) — holds all
Terraform state files (`platform/terraform.tfstate`,
`spike/ci-vpc/terraform.tfstate`, `spike/microservice/<env>/terraform.tfstate`).
- **DynamoDB table** `nova-tfstate-locks` — the dedicated Terraform state
lock table (NOT `nova-outbox` — the outbox is OOS for nova v1.0). The S3
backend `lock_table` attribute points to this table.
The account + region are resolved from the caller's live credentials
(`sts:GetCallerIdentity`) — NO hardcoded account ID. A marker file
`terraform/bootstrap/.bootstrap_state.json` records the created bucket +
table names (gitignored).
## IAM runner (D-026)
`create_iam_user.py` creates:
- **IAM user** `nova-spike-runner`.
- **Inline/managed policy** `nova-spike-runner-policy` from
`terraform/bootstrap/spike_runner_policy.json`. The JSON uses
`${account_id}` and `${region}` placeholders (NOT hardcoded — D-026);
`create_iam_user.py` substitutes the live account ID + region before
attaching the policy.
- **Initial access key** (printed to stdout; capture or rotate via
`rotate_spike_key.sh`).
### Policy scope (D-026)
The `spike_runner_policy.json` grants the runner the Terraform-deployable
permissions it needs to apply the platform + L2 module stacks:
| Service | Granted | Notes |
|----------------|---------|-------|
| S3 | ✅ | State bucket `nova-tfstate-<account>-<region>` |
| DynamoDB | ✅ | Lock table `nova-tfstate-locks` (D-022) |
| ECS | ✅ | Clusters + services + task definitions |
| ECR | ✅ | Repositories + images |
| ELB | ✅ | ALBs + target groups + listeners |
| IAM | ✅ | Roles + policies (Terraform-managed) |
| EC2 | ✅ | VPCs + subnets + SGs + route tables |
| CloudFront | ✅ | Distributions |
| WAF | ✅ | Web ACLs |
| KMS | ✅ | Customer-managed keys + aliases |
| Lambda | ❌ DROP | Platform Lambda OOS (D-023) |
| Secrets Mgr | ❌ DROP | OOS |
| SNS | ❌ DROP | OOS |
| CostExplorer | ❌ DROP | OOS |
| OIDC provider | ❌ DROP | Onboarding uses assume-role (D-025) |
## NOVA_BOOTSTRAP_AWS_* fallback
The bootstrap scripts accept the root-credential pair via the
`NOVA_BOOTSTRAP_AWS_*` env vars (never committed, never echoed):
```bash
export NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID="<root key>"
export NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY="<root secret>"
export AWS_DEFAULT_REGION="us-east-1"
```
These are the bootstrap-only credentials (used exactly once to create
the state backend + spike runner). The fallback precedence is:
1. `NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID` / `NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY`
(bootstrap root key — highest priority).
2. Standard `AWS_*` env vars / `~/.aws/credentials` profile (for
re-running scripts later with the rotated runner key).
## Steps
1. **Set the bootstrap root key in env** (never commit, never echo):
```bash
export NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID="<root key>"
export NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY="<root secret>"
export AWS_DEFAULT_REGION="us-east-1"
```
2. **Create the state backend** (S3 bucket + DynamoDB lock table):
```bash
python3 terraform/bootstrap/create_state_backend.py
```
Idempotent; writes `terraform/bootstrap/.bootstrap_state.json` marker.
3. **Create the IAM user + scoped policy + initial key**:
```bash
python3 terraform/bootstrap/create_iam_user.py
```
Prints `NOVA_AWS_ACCESS_KEY_ID=<...>` + `NOVA_AWS_SECRET_ACCESS_KEY=<...>`
to stdout (capture if you want the initial key; rotate it before use).
4. **Rotate the spike key** (creates a new key, deactivates+deletes old,
writes the new key to gitignored `.env.secrets`):
```bash
bash scripts/rotate_spike_key.sh
```
5. **Verify** (manual): confirm the caller identity is `nova-spike-runner`
(not root); the S3 bucket + DynamoDB table + IAM user + scoped policy
all exist; `.env.secrets` + `.bootstrap_state.json` are gitignored.
6. **MANUAL:** rotate/deactivate the **root** key in the AWS IAM console
(the user does this, not the script). The bootstrap root key has now
served its one-shot purpose; the spike uses the rotated
`nova-spike-runner` key for all subsequent operations.
## Onboarding (D-025)
Consumer onboarding is handled by `terraform/onboarding/main.tf`, which
creates a per-consumer IAM **role** (not a user) with a trust policy
allowing the platform runner user to assume it via `sts:AssumeRole`
(cross-account assume-role pattern). NO OIDC. See the onboarding root
for variable documentation (`consumer_repo`, `owner_id`, `account_id`,
`region`, `runner_user_arn`).
+93
View File
@@ -0,0 +1,93 @@
"""Nova Platform — Bootstrap: IAM runner user + inline policy.
Creates (idempotently):
- IAM user `nova-spike-runner`.
- Inline policy `nova-spike-runner-policy` attached to the user, read
from `spike_runner_policy.json` (with `${account_id}` + `${region}`
placeholders substituted per D-026 — NOT hardcoded).
- An initial access key if no active key exists; prints
NOVA_AWS_ACCESS_KEY_ID / NOVA_AWS_SECRET_ACCESS_KEY.
Uses NOVA_BOOTSTRAP_AWS_* (fallback NOVA_AWS_* fallback AWS_*).
Engine-agnostic: boto3 calls, not HCL strings.
"""
import json
import os
import re
import sys
from pathlib import Path
import boto3
REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
USER_NAME = "nova-spike-runner"
POLICY_NAME = "nova-spike-runner-policy"
POLICY_PATH = Path(__file__).resolve().parent / "spike_runner_policy.json"
def _get_session():
for prefix in ("NOVA_BOOTSTRAP_AWS", "NOVA_AWS", "AWS"):
key = os.environ.get(f"{prefix}_ACCESS_KEY_ID")
secret = os.environ.get(f"{prefix}_SECRET_ACCESS_KEY")
if key and secret:
return boto3.Session(
aws_access_key_id=key, aws_secret_access_key=secret,
region_name=REGION)
return boto3.Session(region_name=REGION)
def _ensure_user(iam):
try:
iam.get_user(UserName=USER_NAME)
except Exception:
iam.create_user(UserName=USER_NAME)
def _substitute(policy_json, account_id, region):
text = json.dumps(policy_json)
text = text.replace("${account_id}", account_id)
text = text.replace("${region}", region)
return json.loads(text)
def _ensure_policy(iam, account_id, region):
with open(POLICY_PATH) as fh:
policy = _substitute(json.load(fh), account_id, region)
iam.put_user_policy(
UserName=USER_NAME,
PolicyName=POLICY_NAME,
PolicyDocument=json.dumps(policy),
)
def _ensure_key(iam):
keys = iam.list_access_keys(UserName=USER_NAME).get("AccessKeyMetadata", [])
for k in keys:
if k["Status"] == "Active":
return k["AccessKeyId"], None
new = iam.create_access_key(UserName=USER_NAME)["AccessKey"]
return new["AccessKeyId"], new["SecretAccessKey"]
def main(argv=None):
session = _get_session()
account_id = session.client("sts").get_caller_identity()["Account"]
iam = session.client("iam")
_ensure_user(iam)
_ensure_policy(iam, account_id, REGION)
key_id, secret = _ensure_key(iam)
print(f"=== IAM RUNNER READY ===")
print(f" user: {USER_NAME}")
print(f" policy: {POLICY_NAME}")
if secret:
print(f"NOVA_AWS_ACCESS_KEY_ID={key_id}")
print(f"NOVA_AWS_SECRET_ACCESS_KEY={secret}")
else:
print(f" (existing active key: {key_id})")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,94 @@
"""Nova Platform — Bootstrap: S3 state backend + DynamoDB lock table.
Creates (idempotently):
- S3 bucket `nova-tfstate-<account_id>-<region>` with versioning enabled.
- DynamoDB table `nova-tfstate-locks` for state locking (D-022 — NOT
`nova-outbox`; the outbox is out of scope).
Uses NOVA_BOOTSTRAP_AWS_* (fallback NOVA_AWS_* fallback AWS_*). Writes a
`.bootstrap_state.json` marker on success.
Engine-agnostic: this file uses boto3 calls, NOT HCL strings. The
forbidden engine terms (aws_, terraform, module ", provider ", resource ")
do NOT appear here as code-level logic — only as resource names passed
to boto3 (e.g. `create_bucket`) which are method calls, not HCL.
"""
import json
import os
import sys
from pathlib import Path
import boto3
REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
STATE_BUCKET_PREFIX = "nova-tfstate-"
LOCK_TABLE = "nova-tfstate-locks"
MARKER_PATH = Path(__file__).resolve().parent / ".bootstrap_state.json"
def _get_session():
"""Build a boto3 session from env var precedence."""
for prefix in ("NOVA_BOOTSTRAP_AWS", "NOVA_AWS", "AWS"):
key = os.environ.get(f"{prefix}_ACCESS_KEY_ID")
secret = os.environ.get(f"{prefix}_SECRET_ACCESS_KEY")
if key and secret:
return boto3.Session(
aws_access_key_id=key,
aws_secret_access_key=secret,
region_name=REGION,
)
return boto3.Session(region_name=REGION)
def _account_id(session):
return session.client("sts").get_caller_identity()["Account"]
def _bucket_name(account_id):
return f"{STATE_BUCKET_PREFIX}{account_id}-{REGION}"
def _ensure_s3_bucket(s3, bucket):
try:
s3.head_bucket(Bucket=bucket)
except Exception:
s3.create_bucket(Bucket=bucket, CreateBucketConfiguration={
"LocationConstraint": REGION} if REGION != "us-east-1" else {})
s3.put_bucket_versioning(Bucket=bucket,
VersioningConfiguration={"Status": "Enabled"})
def _ensure_lock_table(dynamodb):
try:
dynamodb.describe_table(TableName=LOCK_TABLE)
except Exception:
dynamodb.create_table(
TableName=LOCK_TABLE,
KeySchema=[{"AttributeName": "LockID", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "LockID", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
dynamodb.get_waiter("table_exists").wait(TableName=LOCK_TABLE)
def main(argv=None):
session = _get_session()
account_id = _account_id(session)
bucket = _bucket_name(account_id)
s3 = session.client("s3")
dynamodb = session.client("dynamodb")
_ensure_s3_bucket(s3, bucket)
_ensure_lock_table(dynamodb)
marker = {"account_id": account_id, "region": REGION,
"state_bucket": bucket, "lock_table": LOCK_TABLE}
MARKER_PATH.write_text(json.dumps(marker, indent=2))
print(f"=== STATE BACKEND READY ===")
print(f" bucket: {bucket}")
print(f" lock_table: {LOCK_TABLE}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,153 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket",
"s3:GetBucketLocation",
"s3:GetBucketVersioning"
],
"Resource": [
"arn:aws:s3:::nova-tfstate-${account_id}-${region}",
"arn:aws:s3:::nova-tfstate-${account_id}-${region}/*"
]
},
{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:UpdateItem",
"dynamodb:Query",
"dynamodb:Scan",
"dynamodb:DescribeTable"
],
"Resource": "arn:aws:dynamodb:${region}:${account_id}:table/nova-tfstate-locks"
},
{
"Effect": "Allow",
"Action": "sts:GetCallerIdentity",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"ecs:Create*",
"ecs:Describe*",
"ecs:Delete*",
"ecs:Update*",
"ecs:Register*",
"ecs:Deregister*",
"ecs:List*"
],
"Resource": "arn:aws:ecs:${region}:${account_id}:*"
},
{
"Effect": "Allow",
"Action": [
"ecr:Create*",
"ecr:Describe*",
"ecr:Delete*",
"ecr:Get*",
"ecr:Batch*",
"ecr:Put*",
"ecr:Upload*",
"ecr:Initiate*",
"ecr:Complete*"
],
"Resource": "arn:aws:ecr:${region}:${account_id}:*"
},
{
"Effect": "Allow",
"Action": [
"elasticloadbalancing:Create*",
"elasticloadbalancing:Describe*",
"elasticloadbalancing:Delete*",
"elasticloadbalancing:Modify*",
"elasticloadbalancing:Register*",
"elasticloadbalancing:Deregister*"
],
"Resource": "arn:aws:elasticloadbalancing:${region}:${account_id}:*"
},
{
"Effect": "Allow",
"Action": [
"iam:Create*",
"iam:Get*",
"iam:Delete*",
"iam:PassRole",
"iam:Attach*",
"iam:Detach*",
"iam:List*",
"iam:Put*"
],
"Resource": "arn:aws:iam::${account_id}:*"
},
{
"Effect": "Allow",
"Action": [
"ec2:Create*",
"ec2:Describe*",
"ec2:Delete*",
"ec2:Associate*",
"ec2:Disassociate*",
"ec2:Attach*",
"ec2:Detach*",
"ec2:Authorize*"
],
"Resource": "arn:aws:ec2:${region}:${account_id}:*"
},
{
"Effect": "Allow",
"Action": [
"cloudfront:Create*",
"cloudfront:Describe*",
"cloudfront:Get*",
"cloudfront:List*",
"cloudfront:Update*",
"cloudfront:Delete*",
"cloudfront:TagResource",
"cloudfront:UntagResource"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"wafv2:Create*",
"wafv2:Describe*",
"wafv2:Get*",
"wafv2:List*",
"wafv2:Update*",
"wafv2:Delete*"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"kms:CreateKey",
"kms:CreateAlias",
"kms:Describe*",
"kms:Get*",
"kms:List*",
"kms:Update*",
"kms:Delete*",
"kms:EnableKey",
"kms:DisableKey",
"kms:ScheduleKeyDeletion",
"kms:TagResource",
"kms:UntagResource"
],
"Resource": [
"arn:aws:kms:*:*:key/*",
"arn:aws:kms:*:*:alias/nova-*"
]
}
]
}
+115
View File
@@ -0,0 +1,115 @@
# Nova CI VPC — short-lived VPC for L1 module lifecycle testing (D-024).
#
# Created by the modules-lifecycle pipeline before testing VPC-dependent
# modules (alb, ecs-service, rds, uptime). Destroyed after all tests
# complete. Separate from the long-lived platform VPC (terraform/platform).
#
# State: spike/ci-vpc/terraform.tfstate (separate from platform/ and module states)
terraform {
required_version = ">= 1.9, < 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
# Partial backend config — the bucket name is supplied at `terraform init`
# time via `-backend-config=bucket=...` (the bucket is created by
# terraform/bootstrap/create_state_backend.py as nova-tfstate-<account>-<region>).
# This keeps the HCL free of a hardcoded account ID (D-026 spirit).
backend "s3" {
key = "spike/ci-vpc/terraform.tfstate"
region = "us-east-1"
}
}
provider "aws" {
region = "us-east-1"
}
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "ci" {
cidr_block = "10.1.0.0/16"
tags = {
Name = "nova-ci-vpc"
"nova:owner" = "nova"
"nova:environment" = "ci"
}
}
resource "aws_subnet" "ci" {
count = 2
vpc_id = aws_vpc.ci.id
cidr_block = cidrsubnet(aws_vpc.ci.cidr_block, 8, count.index + 1)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "nova-ci-subnet-${count.index}"
"nova:owner" = "nova"
"nova:environment" = "ci"
}
}
resource "aws_internet_gateway" "ci" {
vpc_id = aws_vpc.ci.id
tags = {
Name = "nova-ci-igw"
}
}
resource "aws_route_table" "ci" {
vpc_id = aws_vpc.ci.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.ci.id
}
}
resource "aws_route_table_association" "ci" {
count = 2
subnet_id = aws_subnet.ci[count.index].id
route_table_id = aws_route_table.ci.id
}
resource "aws_security_group" "ecs" {
name = "nova-ci-ecs-sg"
description = "Security group for CI ECS services"
vpc_id = aws_vpc.ci.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_ecs_cluster" "ci" {
name = "nova-ci-cluster"
}
output "vpc_id" {
value = aws_vpc.ci.id
}
output "subnet_ids" {
value = join(",", aws_subnet.ci[*].id)
}
output "ecs_security_group_id" {
value = aws_security_group.ecs.id
}
output "cluster_arn" {
value = aws_ecs_cluster.ci.arn
}
+114
View File
@@ -0,0 +1,114 @@
# Nova sample consumer root — instantiates the L2 microservice pattern.
#
# This is a sample consumer terraform root. It instantiates the L2
# `microservice` module (modules/l2/microservice/terraform), which
# internally composes six L1 primitives (vpc + ecs-cluster + ecs-service +
# iam-role + ecr + alb per D-038) via its own module blocks. The L2 is
# opaque at the stack level (D-012): the consumer root sees ONE module,
# not the individual L1 children.
#
# The platform VPC is referenced via terraform_remote_state (data source)
# so the microservice does not create its own VPC — it reuses the shared
# platform VPC from terraform/platform/main.tf.
#
# State: spike/microservice/<env>/terraform.tfstate (separate from platform/).
terraform {
required_version = ">= 1.9, < 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
# Partial backend config — the bucket name is supplied at `terraform init`
# time via `-backend-config=bucket=...` (the bucket is created by
# terraform/bootstrap/create_state_backend.py as nova-tfstate-<account>-<region>).
backend "s3" {
key = "spike/microservice/dev/terraform.tfstate"
region = "us-east-1"
}
}
provider "aws" {
region = "us-east-1"
}
variable "service_name" {
description = "Name of the ECS microservice."
type = string
default = "nova-sample-app"
}
variable "container_image" {
description = "Container image to deploy (ECR URL)."
type = string
}
variable "desired_count" {
description = "Number of ECS Fargate tasks to run."
type = number
default = 1
}
variable "container_port" {
description = "Container port the service listens on."
type = number
default = 80
}
variable "platform_state_bucket" {
description = "S3 bucket holding the platform VPC state (nova-tfstate-<account>-<region>)."
type = string
}
variable "platform_state_key" {
description = "S3 key for the platform VPC state (default platform/terraform.tfstate)."
type = string
default = "platform/terraform.tfstate"
}
variable "platform_state_region" {
description = "Region of the platform state bucket."
type = string
default = "us-east-1"
}
# Reference the shared platform VPC via terraform_remote_state. The L2
# microservice module consumes these outputs to wire the ALB + ECS service
# into the platform subnets / security group (no per-contract VPC).
data "terraform_remote_state" "platform" {
backend = "s3"
config = {
bucket = var.platform_state_bucket
key = var.platform_state_key
region = var.platform_state_region
}
}
# The L2 microservice pattern — opaque at this level (D-012). Internally
# composes vpc + ecs-cluster + ecs-service + iam-role + ecr + alb (D-038).
module "microservice" {
source = "../modules/l2/microservice/terraform"
service_name = var.service_name
desired_count = var.desired_count
container_image = var.container_image
container_port = var.container_port
# The L2 module reads the platform VPC outputs from this data source
# (subnets, security group) via its own internal wiring — the L2
# interface is intentionally simplified (D-012/D-013).
platform_subnet_ids = split(",", data.terraform_remote_state.platform.outputs.subnet_ids)
platform_security_group_id = data.terraform_remote_state.platform.outputs.ecs_security_group_id
}
output "service_arn" {
description = "The ARN of the deployed ECS service."
value = module.microservice.service_arn
}
output "lb_dns_name" {
description = "The DNS name of the fronting Application Load Balancer."
value = module.microservice.lb_dns_name
}
+226
View File
@@ -0,0 +1,226 @@
# Nova consumer onboarding — IAM ROLE for cross-account deploy (D-025).
#
# Creates an IAM ROLE (not a user) with a trust policy allowing the platform
# runner user to assume it via sts:AssumeRole (cross-account assume-role
# pattern). NO OIDC (OIDC is the production path, OOS for nova v1.0; the
# consumer's CI runner assumes this role via `sts assume-role` using the
# platform runner's static credentials).
#
# Variables consumer_repo + owner_id are kept for tagging (nova:contract /
# nova:owner ABAC tags). The inline policy grants Terraform-deployable
# permissions scoped via tags. lambda:InvokeFunctionUrl is DROPPED (the
# platform Lambda is OOS per D-023).
terraform {
required_version = ">= 1.9, < 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
variable "consumer_repo" {
description = "The consumer repository (org/repo) — for the nova:contract tag."
type = string
default = "acdl/consumer-a"
}
variable "owner_id" {
description = "The owning team (for the nova:owner ABAC tag)."
type = string
default = "team-a"
}
variable "account_id" {
description = "The consumer's AWS account ID (where the deploy role is created)."
type = string
default = "000000000000"
}
variable "region" {
description = "AWS region."
type = string
default = "us-east-1"
}
variable "runner_user_arn" {
description = "The ARN of the platform runner user (nova-spike-runner) that is permitted to assume this deploy role. This is the cross-account trust principal (D-025 — no OIDC)."
type = string
}
provider "aws" {
region = var.region
}
# P20 (REQ-184): consumer deploy role — the role the consumer's CI runner
# assumes to deploy via the reusable workflow. The trust policy allows the
# platform's runner user to assume this role (cross-account assume-role,
# D-025). NO OIDC, NO web identity.
resource "aws_iam_role" "consumer_deploy" {
name = "nova-${replace(var.consumer_repo, "/", "-")}-deploy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
# D-025: cross-account assume-role trust on the platform runner
# user ARN (NO OIDC federated principal). The consumer's CI
# runner uses the platform runner's static credentials to assume
# this role.
AWS = var.runner_user_arn
}
Action = "sts:AssumeRole"
}
]
})
tags = {
"nova:owner" = var.owner_id
"nova:contract" = var.consumer_repo
"nova:environment" = "dev"
}
}
# P20 (REQ-184): inline policy granting the consumer's deploy role the
# Terraform-deployable permissions scoped via ABAC (aws:PrincipalTag/
# nova:owner == var.owner_id). D-025 drops lambda:InvokeFunctionUrl (the
# platform Lambda is OOS per D-023); this policy grants the IAM/EC2/ECS/
# S3/DynamoDB-lock permissions needed for the consumer to run terraform
# against their own account resources.
resource "aws_iam_role_policy" "consumer_deploy" {
name = "nova-consumer-deploy"
role = aws_iam_role.consumer_deploy.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
# IAM — role/policy management for consumer stacks.
"iam:CreateRole",
"iam:GetRole",
"iam:ListRoles",
"iam:DeleteRole",
"iam:UpdateRole",
"iam:TagRole",
"iam:UntagRole",
"iam:PutRolePolicy",
"iam:GetRolePolicy",
"iam:DeleteRolePolicy",
"iam:PassRole"
]
Resource = "arn:aws:iam::${var.account_id}:role/nova-*"
Condition = {
StringEquals = {
"aws:PrincipalTag/nova:owner" = var.owner_id
}
}
},
{
Effect = "Allow"
Action = [
# EC2 — VPC/subnet/SG/route table for consumer stacks.
"ec2:CreateVpc",
"ec2:CreateSubnet",
"ec2:CreateSecurityGroup",
"ec2:CreateRouteTable",
"ec2:CreateInternetGateway",
"ec2:Describe*",
"ec2:DeleteVpc",
"ec2:DeleteSubnet",
"ec2:DeleteSecurityGroup",
"ec2:DeleteRouteTable",
"ec2:DeleteInternetGateway",
"ec2:Associate*",
"ec2:Disassociate*",
"ec2:Attach*",
"ec2:Detach*",
"ec2:Authorize*"
]
Resource = "*"
Condition = {
StringEquals = {
"aws:PrincipalTag/nova:owner" = var.owner_id
}
}
},
{
Effect = "Allow"
Action = [
# ECS — cluster/service/task definitions for consumer stacks.
"ecs:Create*",
"ecs:Describe*",
"ecs:Delete*",
"ecs:Update*",
"ecs:Register*",
"ecs:Deregister*",
"ecs:List*"
]
Resource = "arn:aws:ecs:${var.region}:${var.account_id}:*"
Condition = {
StringEquals = {
"aws:PrincipalTag/nova:owner" = var.owner_id
}
}
},
{
Effect = "Allow"
Action = [
# S3 — state bucket access for consumer stacks.
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket",
"s3:GetBucketLocation",
"s3:GetBucketVersioning"
]
Resource = [
"arn:aws:s3:::nova-tfstate-*",
"arn:aws:s3:::nova-tfstate-*/*"
]
Condition = {
StringEquals = {
"aws:PrincipalTag/nova:owner" = var.owner_id
}
}
},
{
Effect = "Allow"
Action = [
# DynamoDB — state lock table (nova-tfstate-locks, D-022).
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:UpdateItem",
"dynamodb:DescribeTable"
]
Resource = "arn:aws:dynamodb:${var.region}:${var.account_id}:table/nova-tfstate-locks"
Condition = {
StringEquals = {
"aws:PrincipalTag/nova:owner" = var.owner_id
}
}
},
{
Effect = "Allow"
Action = "sts:GetCallerIdentity"
Resource = "*"
}
]
})
}
output "consumer_deploy_role_arn" {
description = "The ARN of the consumer deploy role."
value = aws_iam_role.consumer_deploy.arn
}
output "consumer_deploy_role_name" {
description = "The name of the consumer deploy role."
value = aws_iam_role.consumer_deploy.name
}
+148
View File
@@ -0,0 +1,148 @@
# Nova platform infrastructure — ONLY the shared platform VPC (D-023).
#
# Drops Lambda/DynamoDB-contracts/KMS/Secrets/SNS/consumer_invoke_policy from
# the reference (all OOS for nova v1.0). All consumer stacks reference this
# VPC via terraform_remote_state (data source); no per-contract VPC ever.
#
# State: platform/terraform.tfstate (separate from spike/ and microservice/).
terraform {
required_version = ">= 1.9, < 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
# Partial backend config — the bucket name is supplied at `terraform init`
# time via `-backend-config=bucket=...` (the bucket is created by
# terraform/bootstrap/create_state_backend.py as nova-tfstate-<account>-<region>).
backend "s3" {
key = "platform/terraform.tfstate"
region = "us-east-1"
}
}
provider "aws" {
region = "us-east-1"
}
# v1.14 (REQ-154): VPC CIDR is parameterized (default 10.0.0.0/16).
variable "vpc_cidr" {
description = "CIDR block for the shared platform VPC (default 10.0.0.0/16)."
type = string
default = "10.0.0.0/16"
}
data "aws_availability_zones" "available" {
state = "available"
}
# ---------------------------------------------------------------------------
# Single shared platform VPC — all consumer stacks reference this VPC via
# terraform_remote_state (data source). No per-contract VPC ever again.
# ---------------------------------------------------------------------------
resource "aws_vpc" "nova_shared" {
cidr_block = var.vpc_cidr
tags = {
Name = "nova-shared"
"nova:owner" = "nova"
"nova:contract" = "platform"
"nova:environment" = "shared"
"nova:cost-center" = "nova-default"
}
}
resource "aws_subnet" "nova_shared" {
count = 2
vpc_id = aws_vpc.nova_shared.id
cidr_block = cidrsubnet(aws_vpc.nova_shared.cidr_block, 8, count.index + 1)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "nova-shared-subnet-${count.index}"
"nova:owner" = "nova"
"nova:contract" = "platform"
"nova:environment" = "shared"
"nova:cost-center" = "nova-default"
}
}
resource "aws_internet_gateway" "nova_shared" {
vpc_id = aws_vpc.nova_shared.id
tags = {
Name = "nova-shared-igw"
"nova:owner" = "nova"
"nova:contract" = "platform"
"nova:environment" = "shared"
"nova:cost-center" = "nova-default"
}
}
resource "aws_route_table" "nova_shared" {
vpc_id = aws_vpc.nova_shared.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.nova_shared.id
}
tags = {
Name = "nova-shared-rt"
"nova:owner" = "nova"
"nova:contract" = "platform"
"nova:environment" = "shared"
"nova:cost-center" = "nova-default"
}
}
resource "aws_route_table_association" "nova_shared" {
count = 2
subnet_id = aws_subnet.nova_shared[count.index].id
route_table_id = aws_route_table.nova_shared.id
}
resource "aws_security_group" "ecs" {
name = "nova-ecs-sg"
description = "Security group for ECS Fargate services (platform VPC)"
vpc_id = aws_vpc.nova_shared.id
# Ingress on port 80 is open to 0.0.0.0/0 — this is acceptable because
# the ECS service is fronted by a public-facing ALB (the ALB terminates
# TLS + routes to the target group). The ECS SG should not be attached
# directly to resources without an ALB in front. v1.14 (REQ-154).
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "nova-ecs-sg"
"nova:owner" = "nova"
"nova:contract" = "platform"
"nova:environment" = "shared"
"nova:cost-center" = "nova-default"
}
}
output "vpc_id" {
value = aws_vpc.nova_shared.id
description = "The shared platform VPC ID. Consumer stacks reference this via terraform_remote_state."
}
output "subnet_ids" {
value = join(",", aws_subnet.nova_shared[*].id)
description = "Comma-separated subnet IDs in the shared platform VPC."
}
output "ecs_security_group_id" {
value = aws_security_group.ecs.id
description = "Security group ID for ECS Fargate services in the platform VPC."
}