docs(milestone): complete v1.0-nova-platform (release v0.1.6)
---ci--- project: nova-platform milestone: v1.0 status: complete requirements: covered: [REQ-01,REQ-02,REQ-03,REQ-04,REQ-05,REQ-06,REQ-07,REQ-08,REQ-09,REQ-10,REQ-11,REQ-12,REQ-13,REQ-14,REQ-15,REQ-16,REQ-17,REQ-18,REQ-19,REQ-20,REQ-21,REQ-22,REQ-23,REQ-24,REQ-25,REQ-26,REQ-27,REQ-28,REQ-29,REQ-30,REQ-31,REQ-32,REQ-33,REQ-34,REQ-35,REQ-36,REQ-37,REQ-38] partial: [] ---/ci--- v1.0 milestone complete: simplified infrastructure-delivery platform derived from Nova (acdl). 6 phases (P0-P5 + P6 final). 38 REQ-IDs. 38 decisions (D-001..D-038). 76 tests pass. Engine boundary holds. Happy paths green (check-only + CI). 13 L1 + 2 L2 modules. 5 terraform roots. Shell reproducibility. Zero OOS files. Tags: v0.1.0 (P0) → v0.1.1..v0.1.5 (P1..P5) → v0.1.6 (P6 = milestone release on v0.1 patch line).
This commit is contained in:
@@ -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`).
|
||||
@@ -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-*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
Reference in New Issue
Block a user