Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a03b0e1e5 | |||
| fe9ab7e444 | |||
| 2443909362 |
+10
-16
@@ -1,20 +1,14 @@
|
||||
{
|
||||
"phase": 3,
|
||||
"stage": "verify",
|
||||
"milestone": "v1.0",
|
||||
"phase_role": "execution",
|
||||
"phase": 0,
|
||||
"stage": "specify",
|
||||
"milestone": "v1.1",
|
||||
"phase_role": "pre_execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-20T19:45:00Z",
|
||||
"updated_at": "2026-08-28T19:09:26Z",
|
||||
"project": "nova-platform",
|
||||
"milestone_branch": "milestone/v1.0-nova-platform",
|
||||
"phase_branch": "phase/03-l1-primitives-registry",
|
||||
"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": {
|
||||
"tests_pass": true,
|
||||
"tests_count": 68,
|
||||
"reqs_covered": ["REQ-10", "REQ-11", "REQ-13", "REQ-34"]
|
||||
},
|
||||
"next_phase": "phase/04-l2-patterns-bootstrap-platform"
|
||||
"milestone_branch": "milestone/v1.1-pdlc-state",
|
||||
"phase_branch": "phase/00-pre-execution",
|
||||
"milestone_complete": false,
|
||||
"pdlc_intake": true,
|
||||
"state_md_created": true
|
||||
}
|
||||
+6
-3
@@ -58,9 +58,12 @@ documented. Ships as `v0.1.5`.
|
||||
### Phase 6 — Final Review + Ship (milestone release)
|
||||
|
||||
REVIEW + AUDIT + milestone SHIP. Merges `phase/06` →
|
||||
`milestone/v1.0-nova-platform` → `main`. Tags `v1.0.0` (major — the
|
||||
initial release). Creates the Gitea release. Deletes all milestone
|
||||
branches.
|
||||
`milestone/v1.0-nova-platform` → `main`. Tags `v0.1.6` (the milestone
|
||||
release on the v0.1 patch line — v1.0 is the milestone *label*, tags
|
||||
run on the previous minor's patch line per branch-strategy.md; since
|
||||
there is no prior minor, v0.1.x is the patch line and v0.1.6 IS the
|
||||
v1.0 milestone release). Creates the Gitea release. Deletes all
|
||||
milestone branches.
|
||||
|
||||
## Coverage (init estimate — refined by PLAN)
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# STATE — Nova Platform
|
||||
|
||||
> PDLC Phase 0 intake. Absolute ground truth as of 2026-08-28T19:09Z.
|
||||
> Single-pass discovery for the Product Owner/Manager.
|
||||
|
||||
---
|
||||
|
||||
## 1. Header
|
||||
|
||||
Project: nova-platform
|
||||
Initiative: PDLC Phase 0 — State Intake for next milestone planning
|
||||
Initiator: CIAgent (automated harness)
|
||||
Date (UTC): 2026-08-28T19:09:26Z
|
||||
Current Version: v1.0 milestone complete; release v0.1.6 on main; no active milestone in progress
|
||||
System Health: GREEN — 76/76 tests pass, engine boundary holds, zero OOS files, both happy paths green
|
||||
Raw Idea (≤ 3 sentences):
|
||||
Nova Platform v1.0 shipped a simplified infrastructure-delivery platform (contract → resolve → terraform adapter → apply) derived from the Nova/acdl reference, with security/audit/identity/CI-workflow machinery deliberately excluded.
|
||||
This intake was triggered by the PDLC system instruction to produce a STATE.md for the Product Owner to review before planning the next milestone.
|
||||
The desired outcome is a single-pass ground-truth snapshot enabling the PO to ask pointed Phase 1 questions without back-and-forth.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture State
|
||||
|
||||
Active Layers (which exist and are stable):
|
||||
[x] Core Primitives — 13 L1 modules: s3, vpc, ecs-cluster, ecs-service, iam-role, alb, ecr, cloudfront, waf, rds, kms-key, dynamodb, uptime
|
||||
[x] Domain Modules — 2 L2 patterns: microservice (vpc+ecs-cluster+ecs-service+iam-role+ecr+alb per D-038), static-assets (s3+cloudfront+kms-key per D-038)
|
||||
[x] API/Dev Surface — contract schema (array infrastructure D-015), stack schema (flat, no source D-037), environment schema (simplified D-017/D-018); resolver (core/contract_resolver.py), environment_check (core/environment_check.py), terraform adapter (adapters/terraform/adapter.py)
|
||||
[ ] UI/Agent Surface — N/A (no frontend, no agent UI; frontend-engineer persona deactivated per D-006)
|
||||
|
||||
Compute Topology (per environment):
|
||||
local: abstract (offline --check-only: contract → resolve → adapter → validate; no AWS, no terraform binary)
|
||||
dev: serverless (ECS Fargate + ALB + S3 + CloudFront via terraform apply; platform-managed VPC in terraform/platform/)
|
||||
staging: N/A — UNKNOWN — needs investigation (no qa.json environment file created; only dev.json exists)
|
||||
prod: N/A — UNKNOWN — needs investigation (no prod.json environment file created)
|
||||
dr: N/A — UNKNOWN — needs investigation (no dr.json environment file created)
|
||||
|
||||
Identity Stack in Force:
|
||||
auth: N/A — out of scope (Nova-idp, PAT lifecycle, ABAC evaluator explicitly OOS per D-007)
|
||||
token-vend: N/A — out of scope
|
||||
signing: N/A — out of scope (KMS signing, JWS attestation explicitly OOS)
|
||||
session: N/A — out of scope
|
||||
|
||||
Audit Stream:
|
||||
source of truth: N/A — out of scope (outbox writer, audit ledger, attestation matrix explicitly OOS per D-007)
|
||||
in-repo fallback: no
|
||||
retention policy: N/A
|
||||
|
||||
---
|
||||
|
||||
## 3. Technical Stack (concrete, not aspirational)
|
||||
|
||||
Language(s) and runtime(s): Python 3.11 (>=3.11 required per pyproject.toml); Bash (shell scripts); HCL (Terraform 1.9.* pinned)
|
||||
Build / packaging: pyproject.toml (setuptools backend); no CLI package (D-027 — scripts invoked via shell, no [project.scripts]); requirements-test.txt pins 5 deps
|
||||
CI / CD: Local shell only (scripts/run_ci.sh: lint → test → check-only); NO .github/workflows (OOS per D-007); NO pipelines/ central contract (OOS)
|
||||
Infrastructure: AWS (S3 state backend, DynamoDB lock table, ECS Fargate, ALB, CloudFront, WAF, RDS, KMS, ECR, IAM); terraform/bootstrap/ scripts create S3 bucket nova-tfstate-<account>-<region> + DynamoDB nova-tfstate-locks (D-022); only dev.json environment exists (account_id placeholder 000000000000 for offline)
|
||||
Data stores: S3 (state backend, static assets), DynamoDB (state locking — nova-tfstate-locks; also L1 dynamodb primitive for consumer tables), RDS (L1 rds primitive)
|
||||
Secrets / KMS: Static AWS key only (NOVA_AWS_* in .ciagent/.env.secrets, chmod 600, gitignored); NO OIDC (OOS per D-025); KMS via L1 kms-key primitive (consumer-side, not platform-side); NOVA_FORGE_TOKEN for gitea release (currently blank in .env.secrets — release pending)
|
||||
External integrations in scope: gitea @ https://git.cloudinit.dev/continuous-intelligence/nova-platform (release forge; token NOVA_FORGE_TOKEN); AWS (boto3 for bootstrap scripts + terraform apply)
|
||||
|
||||
---
|
||||
|
||||
## 4. Active Constraints (the load-bearing ones)
|
||||
|
||||
Locked Decisions: D-001 (milestone type major, first release), D-002 (all 13 L1 + 2 L2 in v1.0), D-003 (branching_strategy phase), D-004 (auto_commit/auto_push true), D-005 (test_first false), D-006 (personas: lead+data+backend active, frontend deactivated), D-007 (config.policy removed, ideation drops security), D-008 (secrets.scopes keeps forge + model-backend), D-009 (ship.confirm_before_ship true), D-010 (telemetry.persist true), D-011 (resolver modules_dir param, file reads permitted), D-012 (L2 opaque, flat stack, no children/wires expansion), D-013 (L2 interface.json + terraform_dir in registry), D-014 (interface.json: no nfrs, no intra_refs), D-015 (infrastructure is ARRAY, version optional), D-016 (interpolation ${env.*}/${contract.*} kept), D-017 (environment.schema.json kept simplified), D-018 (env fields: name/account_id/region/state_backend/network; no runner_role_arn/autonomy/confidence_threshold), D-019 (check() returns dict, raises EnvironmentNotFoundError), D-020 (run_platform.sh stages: check-only/plan-only/apply), D-021 (run_ci.sh glob py_compile), D-022 (lock table nova-tfstate-locks, NOT nova-outbox), D-023 (platform/main.tf = ONLY shared VPC), D-024 (ci-vpc = short-lived test VPC), D-025 (onboarding = IAM role cross-account assume, NOT OIDC, NOT user), D-026 (runner policy account parameterized, NOT hardcoded), D-027 (no CLI package, scripts only), D-028 (docs/modules/index links to modules/*/README.md), D-029 (drop STANDARDS.md, keep README-TEMPLATE.md trimmed), D-030 (moto pinned, minimal use), D-031 (run_platform.sh flags: --check-only/--plan-only/--quiet/--help only), D-032 (NOVA_* env prefix, NOVA_AWS_* → AWS_* copy then unset), D-033 (per-env contract variants differ ONLY in environment field), D-034 (engine-boundary test scans .py stripped of docstrings/comments), D-035 (index_document is L2 static-assets input, NOT L1 s3), D-036 (L2 children: microservice=vpc+ecs-cluster+ecs-service+iam-role+ecr+alb, static-assets=s3+cloudfront+kms-key), D-037 (adapter loads registry, no source in stack — grill C-1 fix), D-038 (L2 compositions locked, grill C-2 fix)
|
||||
|
||||
Active Invariants: No INV-* IDs formally registered (v1.0 used D-* decisions, not INV-* invariants). Load-bearing invariants: (1) engine-agnostic core — no aws_*/terraform/module "/provider "/resource " in .py outside adapters/terraform/ (verified by tests/test_engine_boundary.py); (2) structural conventions preserved with 9 locked deviations (D-012, D-013, D-015, D-017, D-018, D-019, D-022, D-023, D-025, D-027); (3) no OOS-creep — zero files from the PROJECT.md/REQUIREMENTS.md OOS list exist in the repo.
|
||||
|
||||
Standing Capability Gate: N/A — no GATE-* IDs registered
|
||||
|
||||
Anti-Goals Touched: PROJECT.md §"Out of scope" + REQUIREMENTS.md §"Out of scope (locked — do NOT implement in v1.0)": security/policy (kyverno, Wiz, Checkov, PolicyEngine), confidence signal + evidence outbox, identity/ABAC (Nova-idp, PAT, JWS, KMS signing, SoD, HITL, attestation, submission_readiness), CI/CD pipeline (.github/workflows, pipelines/, pipeline schemas), metrics/telemetry, leadership decks, decommission/env_transition/mode_resolver/onboarding flow beyond bootstrap, multi-project mode, OIDC federation.
|
||||
|
||||
Out-of-Scope (hard): kyverno-json adapter, Wiz adapter, Checkov custom rules, core/policy_engine.py, core/confidence_signal.py, core/outbox_writer.py, schemas/policy_check_result.schema.json, schemas/metrics_*.schema.json, audit ledger, attestation matrix, core/abac_evaluator.py, core/auth_store.py, core/jws_attestation.py, core/kms_signing.py, core/pat_lifecycle.py, core/separation_of_duties.py, core/hitl_gates.py, core/attestation_matrix.py, core/submission_readiness.py, .github/workflows/ci.yml, .github/workflows/deploy.yml, pipelines/, schemas/pipeline.schema.json, schemas/deploy-pipeline.schema.json, metrics/, core/metrics/, core/regression_verify*.py, modules/STANDARDS.md, PPTX/marp slides, core/env_transition.py, core/decommission_transform.py, core/mode_resolver.py, core/onboarding.py, OIDC federation.
|
||||
|
||||
---
|
||||
|
||||
## 5. Recent History & Quality Gates (last 1-2 milestones)
|
||||
|
||||
Last Shipped: v1.0 milestone — 2026-08-25 (tag v0.1.6 on main); delivered: simplified infrastructure-delivery platform with 13 L1 + 2 L2 modules, contract surface, resolver, terraform adapter, engine boundary, 5 terraform roots, bootstrap scripts, shell reproducibility, 76 tests, docs; 38 REQ-IDs all complete; 38 decisions (D-001..D-038)
|
||||
In Progress: No active milestone in progress (v1.0 complete; checkpoint cleared; next /ci-run starts a new milestone)
|
||||
Coverage Floor: UNKNOWN — needs investigation (pytest-cov not installed; no coverage measurement configured in pyproject.toml addopts; D-030 dropped pytest-cov as OOS)
|
||||
Recent Incidents: none
|
||||
Known Tensions: (1) NOVA_FORGE_TOKEN blank in .ciagent/.env.secrets — v0.1.6 shipped local-only (tag + merge complete, gitea release pending); the forge repo was created during this intake but the token must be set before releases can be created. (2) Only dev.json environment exists — qa/prod/dr environment files were not created in v1.0 (contracts reference them via enum but environment_check will raise EnvironmentNotFoundError for qa/prod/dr). (3) Coverage measurement is absent — pytest-cov was dropped as OOS but this means no coverage floor is enforceable.
|
||||
|
||||
---
|
||||
|
||||
## 6. Agent Context & Assumptions (Agent Initiators Only)
|
||||
|
||||
Missing Context: (1) qa/prod/dr environment JSON files — not created in v1.0 (only core/environments/dev.json exists); the contract schema allows these environment values but no environment record exists for them. (2) Coverage percentage — pytest-cov is not installed; no coverage data available. (3) NORTH_STAR.md — not created (the reference acdl has one; nova-platform deferred it to a future /ci-run specify stage). (4) Live AWS verification — bootstrap scripts + terraform roots were authored but never applied against real AWS (offline-only verification via --check-only).
|
||||
|
||||
Agent Assumptions: (1) Assumed the gitea forge token from the acdl reference repo (/home/opencode/acdl/.env.secrets) is valid for the nova-platform org — verified via API (200 auth). (2) Assumed the next milestone is v1.1 (incrementing from v1.0) — no ROADMAP.md entry exists for a next milestone yet. (3) Assumed PDLC Phase 0 STATE.md work belongs on a new milestone/phase-0 branch hierarchy (milestone/v1.1-pdlc-state → phase/00-pre-execution) per the branch gate convention. (4) Assumed "merge to the forge upstream but DO NOT perform a release" means: push main + branches to origin, do NOT create a gitea release (no tag push, no release API call).
|
||||
|
||||
---
|
||||
|
||||
## 7. Canonical State References (Version/Hash)
|
||||
|
||||
Vision/Strategy doc: UNKNOWN — needs investigation (NORTH_STAR.md not created; PROJECT.md §"Vision / Core Value" serves as the de facto vision but is not versioned as a strategy doc)
|
||||
Architecture document: v0.1.6 (tag on main commit fe9ab7e); .ciagent/ARCHITECTURE.md + docs/architecture.md are the architecture source of truth
|
||||
Last approved SPEC: N/A — no SPEC-ID system in use; REQUIREMENTS.md (38 REQ-IDs, REQ-01..REQ-38) is the requirements source of truth at commit fe9ab7e
|
||||
Decision log: fe9ab7e (last synced commit on main); decisions D-001..D-035 in .ciagent/CLARIFY.md; D-036..D-038 (grill fixes) in .ciagent/CLARIFY.md §"Grill fixes"; D-001..D-010 also in .ciagent/PROJECT.md
|
||||
Invariants catalog: N/A — no INV-* IDs formally registered; load-bearing invariants documented in STATE.md §4 above
|
||||
@@ -0,0 +1,127 @@
|
||||
# Nova Platform
|
||||
|
||||
> Nova Platform — infrastructure delivery, simplified. A consumer declares
|
||||
> intent in a YAML contract; the platform resolves it to a stack, compiles
|
||||
> it through the Terraform adapter, and applies it. Every deployment is
|
||||
> reproducible from the shell, not just in CI.
|
||||
|
||||
Nova Platform is the **infrastructure-delivery core** of the Nova model.
|
||||
The DevSecOps, identity, audit-ledger, and central CI-pipeline-contract
|
||||
machinery of the reference are intentionally removed. What remains: a
|
||||
consumer writes a small YAML contract that names one or more modules by
|
||||
name + version, selects an environment, and supplies module-specific
|
||||
inputs. The platform resolves the contract to a stack instance, compiles
|
||||
it through the Terraform adapter, and applies it.
|
||||
|
||||
- **Consumer guide:** [`docs/consumer-guide.md`](docs/consumer-guide.md)
|
||||
- **Contracts:** [`docs/contracts/`](docs/contracts/index.md)
|
||||
- **Environments:** [`docs/environments/`](docs/environments/index.md)
|
||||
- **Architecture:** [`docs/architecture.md`](docs/architecture.md)
|
||||
- **Modules:** [`docs/modules/`](docs/modules/index.md)
|
||||
|
||||
## How to run
|
||||
|
||||
### Quick start (offline, no AWS required)
|
||||
|
||||
The fastest way to verify the platform works — no AWS credentials, no
|
||||
bootstrap, no cost.
|
||||
|
||||
```bash
|
||||
# Install test dependencies
|
||||
pip install -r requirements-test.txt
|
||||
|
||||
# 1. Run the test suite (all offline)
|
||||
python3 -m pytest tests/ -q
|
||||
|
||||
# 2. Run the platform in check-only mode (offline — contract -> resolve ->
|
||||
# adapter -> structure validation). Uses the default sample contract.
|
||||
bash scripts/run_platform.sh --check-only contracts/static-assets.yml
|
||||
# Expected: "=== PLATFORM CHECK OK ==="
|
||||
|
||||
# 3. Reproduce the full CI pipeline locally (lint -> test -> check-only)
|
||||
bash scripts/run_ci.sh
|
||||
# Expected: "=== CI PIPELINE OK ==="
|
||||
|
||||
# Show all run_platform.sh flags:
|
||||
bash scripts/run_platform.sh --help
|
||||
```
|
||||
|
||||
### Run against live AWS (requires credentials + bootstrap)
|
||||
|
||||
> Prerequisites: a platform-managed environment (see
|
||||
> [docs/environments/](docs/environments/index.md); `core/environments/dev.json`
|
||||
> is the sample), AWS credentials for dev (in `.ciagent/.env.secrets`,
|
||||
> gitignored; see [Credentials](#credentials)), `terraform` (pin `>= 1.9, < 1.10`),
|
||||
> `python3` + `boto3` + `jsonschema` + `pyyaml`.
|
||||
|
||||
```bash
|
||||
# 1. Bootstrap the AWS state backend + runner IAM user (one-time, idempotent)
|
||||
# See terraform/bootstrap/README.md for the full runbook.
|
||||
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"
|
||||
python3 terraform/bootstrap/create_state_backend.py
|
||||
python3 terraform/bootstrap/create_iam_user.py # prints the initial key
|
||||
bash scripts/rotate_spike_key.sh # writes .ciagent/.env.secrets
|
||||
|
||||
# 2. Run the full platform pipeline (contract -> environment check -> stack ->
|
||||
# adapter -> terraform init/validate/plan -> apply).
|
||||
bash scripts/run_platform.sh contracts/microservice.yml
|
||||
# Expected: "=== PLATFORM APPLY OK ==="
|
||||
|
||||
# Or plan-only (contract -> stack -> adapter -> terraform plan; no apply):
|
||||
bash scripts/run_platform.sh --plan-only contracts/static-assets.yml
|
||||
|
||||
# Add --quiet to suppress streaming (output to log files only):
|
||||
bash scripts/run_platform.sh --quiet contracts/static-assets.yml
|
||||
```
|
||||
|
||||
### run_platform.sh flags (D-031)
|
||||
|
||||
| Flag | Mode | AWS required | Description |
|
||||
|------|------|--------------|-------------|
|
||||
| `--check-only` | offline | no | contract → resolve → adapter → structure validation |
|
||||
| `--plan-only` | AWS | yes | above + `terraform init`/`validate`/`plan` (no apply) |
|
||||
| `--quiet` | any | — | suppress streaming output |
|
||||
| `-h`, `--help` | — | — | show usage |
|
||||
| *(none)* | apply | yes | full path: above + `terraform apply -auto-approve` |
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Purpose | Status |
|
||||
|------|---------|--------|
|
||||
| `core/` | Platform code: contract resolver, environment check, environments | active |
|
||||
| `schemas/` | JSON Schemas (draft 2020-12): contract, stack, environment | active |
|
||||
| `adapters/terraform/` | The Terraform adapter — the only engine-specific code | active |
|
||||
| `terraform/` | State backend (S3 + DynamoDB) + bootstrap scripts + platform/onboarding/ci-vpc | active |
|
||||
| `modules/` | L1 primitives (13) + L2 patterns (2) + `registry.json`. Each module has `interface.json` + `terraform/` | active |
|
||||
| `contracts/` | Sample consumer contracts (`static-assets.yml`, `microservice.yml`) + per-env variants | active |
|
||||
| `scripts/` | `run_platform.sh` (pipeline runner), `run_ci.sh` (local CI mirror), `rotate_spike_key.sh` | active |
|
||||
| `tests/` | Pytest suite (all offline — resolver, adapter, schemas, engine boundary, environment check) | active |
|
||||
| `docs/` | Documentation: consumer guide, contracts, environments, architecture, modules | active |
|
||||
| `.ciagent/` | CIAgent config + locked decisions (`ARCHITECTURE.md`, `PROJECT.md`, `CLARIFY.md`) | active |
|
||||
|
||||
## Credentials
|
||||
|
||||
Nova Platform uses a **static AWS key** for dev/local operation. There is no
|
||||
zero-trust federation layer in v1.0 — that is out of scope.
|
||||
|
||||
- The runner key is stored in **`.ciagent/.env.secrets`** (gitignored,
|
||||
`chmod 600`) using the `NOVA_AWS_*` prefix (D-032):
|
||||
`NOVA_AWS_ACCESS_KEY_ID`, `NOVA_AWS_SECRET_ACCESS_KEY`.
|
||||
- `scripts/run_platform.sh` copies `NOVA_AWS_*` to the standard `AWS_*`
|
||||
env vars before invoking Terraform, then unsets the `NOVA_*` copies.
|
||||
- Bootstrap uses a one-shot root key via `NOVA_BOOTSTRAP_AWS_*` env vars
|
||||
(never committed, never echoed). See
|
||||
[`terraform/bootstrap/README.md`](terraform/bootstrap/README.md) for the
|
||||
full bootstrap runbook (state backend + runner IAM user + key rotation).
|
||||
- Onboarding creates a per-consumer IAM **role** (cross-account assume-role
|
||||
pattern, D-025) — not a user, not federation. See
|
||||
`terraform/onboarding/main.tf`.
|
||||
|
||||
## Consumer guide
|
||||
|
||||
A step-by-step guide for a consumer to create a repo, write a contract,
|
||||
validate it offline, and run it against AWS is at
|
||||
[`docs/consumer-guide.md`](docs/consumer-guide.md). The guide is generic
|
||||
across all modules; `static-assets` is the worked example.
|
||||
@@ -153,6 +153,7 @@ def resolve(contract, registry, modules_dir, environments_dir=None,
|
||||
|
||||
def main(argv=None):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
argv = argv or sys.argv[1:]
|
||||
if len(argv) < 2:
|
||||
print("usage: contract_resolver.py <contract.yaml> [out.json]",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Architecture
|
||||
|
||||
> **Status:** v1.0 (current). This document mirrors `.ciagent/ARCHITECTURE.md`.
|
||||
> Where the two conflict, `.ciagent/ARCHITECTURE.md` wins.
|
||||
|
||||
## 0. Purpose
|
||||
|
||||
Nova Platform is the **infrastructure-delivery core**. A consumer declares
|
||||
intent in a YAML contract; the platform resolves it to a stack, compiles it
|
||||
through the Terraform adapter, and applies it. The DevSecOps, identity,
|
||||
audit-ledger, and central CI-pipeline-contract machinery of the Nova
|
||||
reference are intentionally removed — see the OOS list below.
|
||||
|
||||
## 1. Layers (4)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ 1. Contract Surface schemas/contract.schema.json
|
||||
│ contracts/*.yml (samples)
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ 2. Resolution core/contract_resolver.py
|
||||
│ core/environment_check.py
|
||||
│ schemas/stack.schema.json
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ 3. Engine Adapter adapters/terraform/ (the only
|
||||
│ (only engine-specific) engine-specific code)
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ 4. Apply terraform/ (bootstrap, modules)
|
||||
│ scripts/run_platform.sh
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Layer 1 — Contract Surface
|
||||
|
||||
A consumer writes a small YAML contract. The `infrastructure` field is an
|
||||
**array** (D-015), not a map:
|
||||
|
||||
```yaml
|
||||
id: stsi
|
||||
name: My Static Site
|
||||
environment: dev
|
||||
infrastructure:
|
||||
- module: static-assets
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
bucket_name: my-static-site-assets
|
||||
index_document: index.html
|
||||
```
|
||||
|
||||
Validated against `schemas/contract.schema.json`. The contract is the only
|
||||
consumer-facing surface. It is engine-agnostic — no `aws_*` terms.
|
||||
|
||||
### Layer 2 — Resolution
|
||||
|
||||
`core/contract_resolver.py` resolves a validated contract to a Stack
|
||||
instance (a typed structure conforming to `schemas/stack.schema.json`).
|
||||
Resolution is pure: contract in, stack out. No I/O beyond local file reads
|
||||
for module metadata (D-011). No engine terms.
|
||||
|
||||
`core/environment_check.py` validates that the named environment exists in
|
||||
`core/environments/*.json` and returns its definition. Environments are
|
||||
platform-managed (consumers provide no AWS account, VPC, or state bucket).
|
||||
|
||||
Interpolation (D-016): the resolver expands `${env.<field>}` and
|
||||
`${contract.<field>}` tokens after the environment is loaded. Unknown tokens
|
||||
raise `ValueError`.
|
||||
|
||||
### Layer 3 — Engine Adapter
|
||||
|
||||
`adapters/terraform/` is the only engine-specific code. It takes a Stack
|
||||
and emits Terraform (`module "x" { source = "../../modules/..." }` blocks).
|
||||
The adapter is a stateless assembler — lifecycle ownership belongs to
|
||||
Terraform via the shell orchestrator. This is the only place `aws_*` /
|
||||
Terraform terms appear.
|
||||
|
||||
### Layer 4 — Apply
|
||||
|
||||
`scripts/run_platform.sh` orchestrates: contract → resolve → adapter →
|
||||
`terraform init` → `terraform plan` → `terraform apply`. Modes (D-031):
|
||||
`--check-only` (offline, structure validation), `--plan-only` (no apply),
|
||||
full (apply). `--quiet` suppresses streaming.
|
||||
|
||||
## 2. Engine Boundary (Enforced)
|
||||
|
||||
The engine boundary is strict. Code outside `adapters/terraform/` MUST NOT
|
||||
contain engine-specific terms (`aws_s3_bucket`, `aws_*`, Terraform HCL).
|
||||
This invariant is verified by tests (`tests/test_engine_boundary.py`,
|
||||
scope per D-034: `.py` files in `core/`, `schemas/`, `contracts/`,
|
||||
`tests/`, `scripts/`, root — excluding `adapters/terraform/`, `modules/`,
|
||||
`.tf`/`.md`/`.json` data files).
|
||||
|
||||
## 3. What is NOT here (intentionally removed vs the reference)
|
||||
|
||||
Nova Platform is a simplified, infrastructure-only platform. The following
|
||||
reference features are **out of scope** for v1.0:
|
||||
|
||||
- No confidence signal — no score gating apply.
|
||||
- No audit outbox — no hash-chained evidence events.
|
||||
- No policy engine / policy adapter — no policy checks.
|
||||
- No identity layer, no attribute-based authorization, no human-in-the-loop
|
||||
approval gates.
|
||||
- No reusable CI workflow — local shell only (`scripts/run_ci.sh`).
|
||||
- No central pipeline contract — no `pipelines/` directory.
|
||||
- No platform telemetry / metrics.
|
||||
- No decommission transform, env-transition transform, or onboarding flow
|
||||
beyond bootstrap.
|
||||
- No leadership decks or slide rendering.
|
||||
|
||||
## 4. Module Catalog
|
||||
|
||||
L1 primitives (single resources) + L2 patterns (composites of primitives).
|
||||
Each module has an `interface.json` (inputs/outputs, no engine terms) and a
|
||||
`terraform/` directory. `modules/registry.json` indexes every module +
|
||||
version.
|
||||
|
||||
**L1 (primitives — 13):** `s3`, `vpc`, `ecs-cluster`, `ecs-service`,
|
||||
`iam-role`, `alb`, `ecr`, `cloudfront`, `waf`, `rds`, `kms-key`,
|
||||
`dynamodb`, `uptime`.
|
||||
|
||||
**L2 (patterns — 2):**
|
||||
|
||||
| Module | Composes (D-038) | Description |
|
||||
|--------|------------------|-------------|
|
||||
| `microservice` | vpc + ecs-cluster + ecs-service + iam-role + ecr + alb (6 L1s) | Container microservice with a public ALB |
|
||||
| `static-assets` | s3 + cloudfront + kms-key (3 L1s) | Static site fronted by CloudFront |
|
||||
|
||||
L2 modules are opaque stack entries (D-012): the resolver does not expand
|
||||
their children. The L2's `terraform/main.tf` composes L1 modules internally
|
||||
via `module` blocks. See [`docs/modules/index.md`](modules/index.md) for the
|
||||
full catalog and each module's README.
|
||||
@@ -0,0 +1,208 @@
|
||||
# Consumer Guide — Declare intent, deploy to AWS
|
||||
|
||||
This guide walks a consumer through creating a repo, writing a contract,
|
||||
validating it offline, and running it against AWS. It is **generic** across
|
||||
all modules in the registry; `static-assets` is the worked example, but
|
||||
every step applies to `microservice` and any future module.
|
||||
|
||||
## The model
|
||||
|
||||
You write a contract YAML file and the platform does the rest. Your
|
||||
repository contains only your application code and your contracts. You do
|
||||
not write infrastructure modules or adapter code.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["your repo<br/>(app code + contract)"] -->|run_platform.sh| B
|
||||
B["platform<br/>(resolver + adapter + modules)"] -->|contract -> stack -> terraform -> apply| C
|
||||
C["your resources in AWS"]
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **A consumer repository** for your application code + contract.
|
||||
- **A platform-managed environment** bound to your repo. The platform team
|
||||
provisions the AWS account, network, and state backend. See
|
||||
[Environments](environments/index.md).
|
||||
- **AWS credentials** for the target environment, in `.ciagent/.env.secrets`
|
||||
(gitignored) using the `NOVA_AWS_*` prefix. See the
|
||||
[platform README](../README.md#credentials) and
|
||||
[`terraform/bootstrap/README.md`](../terraform/bootstrap/README.md).
|
||||
|
||||
## Step 1 — Create a consumer repo
|
||||
|
||||
Create a repository for your application. The top level holds your app
|
||||
code; your contract lives at the repo root (or wherever you point
|
||||
`run_platform.sh`). Example for a static site:
|
||||
|
||||
```
|
||||
my-static-site/
|
||||
index.html
|
||||
assets/
|
||||
style.css
|
||||
logo.png
|
||||
contract.yml
|
||||
```
|
||||
|
||||
Example for a microservice:
|
||||
|
||||
```
|
||||
my-microservice/
|
||||
app.py
|
||||
Dockerfile
|
||||
contract.yml
|
||||
```
|
||||
|
||||
## Step 2 — Define the contract
|
||||
|
||||
Write `contract.yml`. The `static-assets` example:
|
||||
|
||||
```yaml
|
||||
id: stsi
|
||||
name: Static Assets Site
|
||||
environment: dev
|
||||
infrastructure:
|
||||
- module: static-assets
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
bucket_name: "${env.environment}-${contract.id}-assets"
|
||||
index_document: index.html
|
||||
```
|
||||
|
||||
A `microservice` example:
|
||||
|
||||
```yaml
|
||||
id: msvc
|
||||
name: Microservice
|
||||
environment: dev
|
||||
infrastructure:
|
||||
- module: microservice
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
service_name: "${env.environment}-${contract.id}-svc"
|
||||
desired_count: 2
|
||||
```
|
||||
|
||||
### Contract fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | string | yes | Short operational acronym (`^[a-z][a-z0-9-]{2,5}$`, 3-6 chars). Becomes the stack name used for the Terraform state key and resource naming prefix. Stable across deploys and environment promotions. |
|
||||
| `name` | string | yes | Full human-readable stack name (min 3 chars). |
|
||||
| `environment` | string | yes | The platform-managed environment to deploy to (`dev`/`qa`/`prod`/`dr`). See [Environments](environments/index.md). |
|
||||
| `infrastructure` | array | yes | **Array** of modules to deploy (D-015). Each entry carries a `module` name (matching a registry key), an optional `version` (defaults to latest non-deprecated), and required `inputs`. One entry = single-module deploy; N entries = multi-module manifest. |
|
||||
|
||||
### Infrastructure item fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `module` | string | yes | Module name from `modules/registry.json` (`^[a-z][a-z0-9-]*$`). |
|
||||
| `version` | string | no | Semver pin `X.Y.Z`. Omitted = latest non-deprecated version. |
|
||||
| `inputs` | object | yes | Module-specific inputs (see the module's README). No `aws_*` keys — the contract is engine-agnostic. |
|
||||
|
||||
Each module declares its inputs in its `interface.json`. Consult the
|
||||
[module catalog](modules/index.md) for the full list, or read the module's
|
||||
own README under `modules/l1/<name>/` or `modules/l2/<name>/`.
|
||||
|
||||
The contract is validated against `schemas/contract.schema.json`. An invalid
|
||||
contract (missing field, unknown module, wrong type) fails at the
|
||||
validate-contract stage with a clear error.
|
||||
|
||||
### Interpolation reference (D-016)
|
||||
|
||||
The resolver expands `${env.*}` and `${contract.*}` tokens after the
|
||||
environment is loaded. Unknown tokens raise `ValueError` (fail loud).
|
||||
Expansion is recursive (nested map/list values expand too).
|
||||
|
||||
| Token | Resolves to | Example |
|
||||
|-------|-------------|---------|
|
||||
| `${env.name}` | the environment name | `dev` |
|
||||
| `${env.region}` | the environment's AWS region | `us-east-1` |
|
||||
| `${env.account_id}` | the environment's AWS account id | `000000000000` |
|
||||
| `${env.state_backend.bucket}` | the environment's state bucket | `nova-tfstate-dev-us-east-1` |
|
||||
| `${env.state_backend.lock_table}` | the environment's lock table | `nova-tfstate-locks` |
|
||||
| `${env.network.vpc_cidr}` | the environment's VPC CIDR | `10.0.0.0/16` |
|
||||
| `${contract.id}` | the contract's operational acronym | `stsi` |
|
||||
| `${contract.name}` | the contract's name field | `Static Assets Site` |
|
||||
| `${contract.environment}` | the contract's environment field | `dev` |
|
||||
|
||||
## Step 3 — Validate offline (no AWS required)
|
||||
|
||||
Before touching AWS, validate the contract end-to-end offline. Clone the
|
||||
Nova Platform repo and run `--check-only` against your contract:
|
||||
|
||||
```bash
|
||||
bash scripts/run_platform.sh --check-only path/to/your/contract.yml
|
||||
# Expected: "=== PLATFORM CHECK OK ==="
|
||||
```
|
||||
|
||||
This runs: environment check → contract schema validation → resolve to
|
||||
stack → adapter compiles to HCL → output structure validation. No AWS
|
||||
credentials are needed.
|
||||
|
||||
## Step 4 — Run against live AWS
|
||||
|
||||
Once the contract validates offline, run the full pipeline against AWS.
|
||||
Ensure your credentials are in `.ciagent/.env.secrets` (see
|
||||
[Credentials](../README.md#credentials)):
|
||||
|
||||
```bash
|
||||
bash scripts/run_platform.sh contracts/static-assets.yml
|
||||
# Expected: "=== PLATFORM APPLY OK ==="
|
||||
```
|
||||
|
||||
The full path: environment check → validate contract → resolve to stack →
|
||||
adapter compiles to HCL → load AWS credentials → `terraform init` →
|
||||
`terraform validate` → `terraform plan` → `terraform apply -auto-approve`.
|
||||
|
||||
To stop before apply (review the plan only):
|
||||
|
||||
```bash
|
||||
bash scripts/run_platform.sh --plan-only contracts/static-assets.yml
|
||||
# Expected: "=== PLATFORM PLAN OK ==="
|
||||
```
|
||||
|
||||
## Step 5 — What gets created
|
||||
|
||||
After a successful `dev` run, the resources declared by your module's
|
||||
pattern exist in your AWS account.
|
||||
|
||||
For the `static-assets` example (s3 + cloudfront + kms-key, D-038):
|
||||
|
||||
- An **S3 bucket** (named via your `bucket_name` input, interpolation
|
||||
expanded) with versioning enabled.
|
||||
- A **CloudFront distribution** with the S3 bucket as the origin.
|
||||
- A **KMS key** for SSE.
|
||||
|
||||
For other modules, consult the module's README
|
||||
(`modules/l1/<name>/README.md` or `modules/l2/<name>/README.md`) for the
|
||||
exact resources created.
|
||||
|
||||
## Step 6 — Upload your content (static-assets example)
|
||||
|
||||
The platform provisions the infrastructure; you upload your content. For the
|
||||
`static-assets` module:
|
||||
|
||||
```bash
|
||||
aws s3 sync ./assets s3://<your-bucket-name>/
|
||||
```
|
||||
|
||||
For a `microservice`, the platform provisions the ECS service and ALB; you
|
||||
push your container image to the ECR repo the platform created.
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path | Description |
|
||||
|----------|------|-------------|
|
||||
| Contract schema | `schemas/contract.schema.json` | JSON Schema for consumer contracts. |
|
||||
| Stack schema | `schemas/stack.schema.json` | JSON Schema for the resolved stack instance. |
|
||||
| Module catalog | [modules/](modules/index.md) | All primitives and modules. |
|
||||
| Sample contract | `contracts/static-assets.yml` | The reference example contract. |
|
||||
| Sample contract | `contracts/microservice.yml` | The microservice example contract. |
|
||||
| Module examples | `modules/<name>/examples/` | Validated per-module example contracts. |
|
||||
| Contract resolver | `core/contract_resolver.py` | Resolves contracts to stack instances. |
|
||||
| Terraform adapter | `adapters/terraform/adapter.py` | Compiles stack instances to Terraform. |
|
||||
| Pipeline runner | `scripts/run_platform.sh` | The pipeline runner. |
|
||||
| Environments | [environments/](environments/index.md) | Platform-managed environments. |
|
||||
| Platform README | `README.md` | How the platform works + how to run it. |
|
||||
| Credentials | `README.md#credentials` | The static-key model + bootstrap runbook. |
|
||||
@@ -0,0 +1,139 @@
|
||||
# Contracts
|
||||
|
||||
A consumer declares intent in a **contract** — a small YAML file that names
|
||||
infrastructure (one or more modules), selects an environment, and supplies
|
||||
module-specific inputs. The platform validates, resolves, and deploys it.
|
||||
|
||||
## The contract file
|
||||
|
||||
A minimal example (the `static-assets` module):
|
||||
|
||||
```yaml
|
||||
id: stsi
|
||||
name: Static Assets Site
|
||||
environment: dev
|
||||
infrastructure:
|
||||
- module: static-assets
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
bucket_name: "${env.environment}-${contract.id}-assets"
|
||||
index_document: index.html
|
||||
```
|
||||
|
||||
A `microservice` example:
|
||||
|
||||
```yaml
|
||||
id: msvc
|
||||
name: Microservice
|
||||
environment: dev
|
||||
infrastructure:
|
||||
- module: microservice
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
service_name: "${env.environment}-${contract.id}-svc"
|
||||
desired_count: 2
|
||||
```
|
||||
|
||||
## Array-based infrastructure (D-015)
|
||||
|
||||
The `infrastructure` field is an **array** of module entries, not a map
|
||||
keyed by module name. This is a locked deviation from the Nova reference
|
||||
(D-015). Each entry is an object with `module`, `version` (optional), and
|
||||
`inputs`.
|
||||
|
||||
One entry = single-module deploy. N entries = multi-module manifest deployed
|
||||
in one pipeline run:
|
||||
|
||||
```yaml
|
||||
id: app
|
||||
name: Pricing Service API
|
||||
environment: dev
|
||||
infrastructure:
|
||||
- module: microservice
|
||||
version: "1.0.0"
|
||||
inputs: { ... }
|
||||
- module: static-assets
|
||||
version: "1.0.0"
|
||||
inputs: { ... }
|
||||
```
|
||||
|
||||
All modules deploy to the same `environment` in one pipeline run.
|
||||
|
||||
## Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | string | yes | Short operational acronym (`^[a-z][a-z0-9-]{2,5}$`, 3-6 chars). Becomes the stack name used for the Terraform state key and resource naming prefix. Stable across deploys and environment promotions. |
|
||||
| `name` | string | yes | Full human-readable stack name (min 3 chars). |
|
||||
| `environment` | string | yes | The platform-managed environment to deploy to (`dev`/`qa`/`prod`/`dr`). See [Environments](../environments/index.md). |
|
||||
| `infrastructure` | array | yes | Array of modules to deploy (D-015). `minItems: 1`. |
|
||||
|
||||
### Infrastructure item fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `module` | string | yes | Module name from `modules/registry.json` (`^[a-z][a-z0-9-]*$`). |
|
||||
| `version` | string | no | Semver pin (`^\d+\.\d+\.\d+$`). Omitted = latest non-deprecated version from the registry. |
|
||||
| `inputs` | object | yes | Module-specific inputs (see the module's README / `interface.json`). No `aws_*` keys — the contract is engine-agnostic. |
|
||||
|
||||
## Validation
|
||||
|
||||
The contract is validated against
|
||||
[`schemas/contract.schema.json`](../../schemas/contract.schema.json). An
|
||||
invalid contract (missing field, unknown module, wrong type) fails at the
|
||||
validate-contract stage with a clear error.
|
||||
|
||||
## Interpolation tokens (D-016)
|
||||
|
||||
The resolver expands `${env.*}` and `${contract.*}` tokens after the
|
||||
environment is loaded. Unknown tokens raise `ValueError` (fail loud).
|
||||
Expansion is recursive (nested map/list values expand too).
|
||||
|
||||
| Token | Resolves to | Example |
|
||||
|-------|-------------|---------|
|
||||
| `${env.name}` | the environment name | `dev` |
|
||||
| `${env.region}` | the environment's AWS region | `us-east-1` |
|
||||
| `${env.account_id}` | the environment's AWS account id | `000000000000` |
|
||||
| `${env.state_backend.bucket}` | the environment's state bucket | `nova-tfstate-dev-us-east-1` |
|
||||
| `${env.state_backend.lock_table}` | the environment's lock table | `nova-tfstate-locks` |
|
||||
| `${env.network.vpc_cidr}` | the environment's VPC CIDR | `10.0.0.0/16` |
|
||||
| `${env.network.azs}` | the environment's availability zones | `["us-east-1a","us-east-1b"]` |
|
||||
| `${contract.id}` | the contract's operational acronym | `stsi` |
|
||||
| `${contract.name}` | the contract's name field | `Static Assets Site` |
|
||||
| `${contract.environment}` | the contract's environment field | `dev` |
|
||||
|
||||
## Sample contracts
|
||||
|
||||
Two reference examples exist in `contracts/`:
|
||||
|
||||
- [`contracts/static-assets.yml`](../../contracts/static-assets.yml) — the
|
||||
`static-assets` module (s3 + cloudfront + kms-key, D-038).
|
||||
- [`contracts/microservice.yml`](../../contracts/microservice.yml) — the
|
||||
`microservice` module (vpc + ecs-cluster + ecs-service + iam-role + ecr +
|
||||
alb, D-038).
|
||||
|
||||
Additionally, every module has a `modules/<name>/examples/` directory with
|
||||
validated example contracts. See the [module catalog](../modules/index.md)
|
||||
for the full list.
|
||||
|
||||
## Per-environment variants (D-033)
|
||||
|
||||
Each sample contract has per-environment variants that differ **only** in
|
||||
the `environment` field — all other fields are identical. Interpolation
|
||||
resolves environment-specific values at resolver time.
|
||||
|
||||
| File | Environment |
|
||||
|------|-------------|
|
||||
| `contracts/static-assets.yml` | dev (default) |
|
||||
| `contracts/static-assets.dev.yml` | dev |
|
||||
| `contracts/static-assets.qa.yml` | qa |
|
||||
| `contracts/static-assets.prod.yml` | prod |
|
||||
| `contracts/static-assets.dr.yml` | dr |
|
||||
| `contracts/microservice.yml` | dev (default) |
|
||||
| `contracts/microservice.dev.yml` | dev |
|
||||
| `contracts/microservice.qa.yml` | qa |
|
||||
| `contracts/microservice.prod.yml` | prod |
|
||||
| `contracts/microservice.dr.yml` | dr |
|
||||
|
||||
Promotion = running the pipeline against the matching variant. See the
|
||||
[Consumer Guide](../consumer-guide.md) for the end-to-end flow.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Environments
|
||||
|
||||
A consumer does **not** provide an AWS account, a VPC, a subnet, or an S3
|
||||
state bucket. The platform manages environments.
|
||||
|
||||
## What an environment is
|
||||
|
||||
A named environment is a **platform-owned** bundle of:
|
||||
|
||||
- An **AWS account** (or a scoped partition of one).
|
||||
- A **network** (VPC + subnets / AZs).
|
||||
- A **state backend** (an S3 bucket + DynamoDB lock table for Terraform
|
||||
state).
|
||||
|
||||
A consumer selects an environment **by name** in their contract:
|
||||
|
||||
```yaml
|
||||
environment: dev
|
||||
```
|
||||
|
||||
The platform resolves the name to the underlying account/network/state
|
||||
backend at run time. The consumer never sees the raw credentials.
|
||||
|
||||
## Environment definition shape (D-018)
|
||||
|
||||
Each environment is a JSON file in `core/environments/`. The field set is
|
||||
reduced from the reference — the IAM role, autonomy, and confidence
|
||||
threshold fields are out of scope for v1.0.
|
||||
|
||||
`core/environments/dev.json` (the sample):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "dev",
|
||||
"description": "Sample dev environment for offline/local testing. account_id placeholder (000000000000) for offline mode.",
|
||||
"account_id": "000000000000",
|
||||
"region": "us-east-1",
|
||||
"state_backend": {
|
||||
"bucket": "nova-tfstate-dev-us-east-1",
|
||||
"lock_table": "nova-tfstate-locks"
|
||||
},
|
||||
"network": {
|
||||
"vpc_cidr": "10.0.0.0/16",
|
||||
"azs": ["us-east-1a", "us-east-1b"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | yes | The environment name (`dev`/`qa`/`prod`/`dr`). |
|
||||
| `description` | string | no | Human-readable description. |
|
||||
| `account_id` | string | yes | The AWS account id (placeholder `000000000000` for offline dev). |
|
||||
| `region` | string | yes | The AWS region. |
|
||||
| `state_backend.bucket` | string | yes | The S3 state bucket name. |
|
||||
| `state_backend.lock_table` | string | yes | The DynamoDB lock table name (`nova-tfstate-locks` per D-022). |
|
||||
| `network.vpc_cidr` | string | yes | The VPC CIDR block. |
|
||||
| `network.azs` | array | yes | The availability zones. |
|
||||
|
||||
### Dropped from the reference (D-018)
|
||||
|
||||
- `runner_role_arn` — identity/authorization is out of scope.
|
||||
- `autonomy` — human-in-the-loop gates are out of scope.
|
||||
- `confidence_threshold` — the confidence signal is out of scope.
|
||||
|
||||
## State backend (D-022)
|
||||
|
||||
The Terraform state backend uses a dedicated DynamoDB lock table named
|
||||
`nova-tfstate-locks` (NOT `nova-outbox` — the audit outbox is out of scope
|
||||
for v1.0). The S3 state bucket is named `nova-tfstate-<account>-<region>`
|
||||
with versioning enabled. See
|
||||
[`terraform/bootstrap/README.md`](../../terraform/bootstrap/README.md) for
|
||||
the bootstrap runbook that creates both.
|
||||
|
||||
## Autonomy by environment
|
||||
|
||||
| Environment | Autonomy | Operator action |
|
||||
|-------------|----------|-----------------|
|
||||
| dev | Fully autonomous | None — `terraform apply -auto-approve` runs automatically. |
|
||||
| qa | Manual | An operator runs `run_platform.sh` against the `qa` contract. |
|
||||
| prod | Manual | An operator runs `run_platform.sh` against the `prod` contract. |
|
||||
| dr | Manual | An operator runs `run_platform.sh` against the `dr` contract. |
|
||||
|
||||
`dev` is the only autonomous environment. Higher environments require a
|
||||
human operator to invoke the pipeline against the environment's contract
|
||||
variant. There are no automated gates or attestation steps — those are out
|
||||
of scope for v1.0. Staging does not exist.
|
||||
|
||||
## Onboarding scaffold (current state)
|
||||
|
||||
The platform repo ships a minimal onboarding scaffold:
|
||||
|
||||
- [`core/environments/`](../../core/environments/) — environment definitions
|
||||
(a sample `dev.json`).
|
||||
- `core/environment_check.py` — checks whether an environment is defined
|
||||
for a given contract's environment name; raises `EnvironmentNotFoundError`
|
||||
when none is defined (D-019).
|
||||
- `scripts/run_platform.sh` calls the check before contract validation.
|
||||
|
||||
The scaffold is minimal: provisioning a new environment is a platform-team
|
||||
action today (bootstrap the state backend + network). Self-service
|
||||
environment provisioning is a future milestone.
|
||||
+2
-2
@@ -123,5 +123,5 @@ in this repo ship a Terraform adapter under `terraform/`.
|
||||
|
||||
| Module | Composes | Description |
|
||||
|------------------|-------------------------------------------|----------------------------------------------|
|
||||
| `microservice` | vpc + ecs-cluster + ecs-service + alb + ecr | Container microservice with public ALB |
|
||||
| `static-assets` | s3 + cloudfront | Static site fronted by CloudFront |
|
||||
| `microservice` | vpc + ecs-cluster + ecs-service + iam-role + ecr + alb | Container microservice with public ALB (D-038) |
|
||||
| `static-assets` | s3 + cloudfront + kms-key | Static site fronted by CloudFront (D-038) |
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
[project]
|
||||
name = "nova-platform"
|
||||
version = "1.0.0"
|
||||
description = "Nova Platform — simplified infrastructure delivery (contract -> resolve -> terraform adapter -> apply)"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"boto3>=1.34",
|
||||
"jsonschema>=4.20",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=8.0",
|
||||
"moto[dynamodb]>=5.0",
|
||||
"jsonschema>=4.20",
|
||||
"pyyaml>=6.0",
|
||||
"boto3>=1.34",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-v --tb=short"
|
||||
markers = [
|
||||
"offline: offline tests (no AWS)",
|
||||
"slow: slow tests",
|
||||
"shell: shell-invocation tests (excluded from run_ci.sh to avoid recursion)",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning:botocore.*",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["core*", "adapters*"]
|
||||
@@ -0,0 +1,5 @@
|
||||
pytest>=8.0
|
||||
moto[dynamodb]>=5.0
|
||||
jsonschema>=4.20
|
||||
pyyaml>=6.0
|
||||
boto3>=1.34
|
||||
Executable
+34
@@ -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)"
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nova Platform — run_ci.sh
|
||||
#
|
||||
# Local CI pipeline mirror: lint (py_compile) -> test (pytest) ->
|
||||
# check-only (run_platform.sh --check-only).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
QUIET=0
|
||||
[ "${1:-}" = "--quiet" ] && QUIET=1
|
||||
|
||||
banner() { [ "$QUIET" -eq 0 ] && echo "── $1 ──"; }
|
||||
|
||||
banner "Stage 1/3: lint (py_compile)"
|
||||
python3 -m py_compile $(find core/ adapters/ scripts/ -name '*.py' -not -path '*/__pycache__/*')
|
||||
echo "lint: OK"
|
||||
|
||||
banner "Stage 2/3: test (pytest)"
|
||||
python3 -m pytest tests/ -q --tb=short -m "not shell"
|
||||
echo "test: OK"
|
||||
|
||||
banner "Stage 3/3: check-only (run_platform.sh --check-only)"
|
||||
bash scripts/run_platform.sh --check-only contracts/static-assets.yml
|
||||
echo "check-only: OK"
|
||||
|
||||
echo "=== CI PIPELINE OK ==="
|
||||
echo "3 stages passed: lint, test, check-only"
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nova Platform — run_platform.sh
|
||||
#
|
||||
# Orchestrates: contract -> resolve -> adapter -> apply.
|
||||
#
|
||||
# Flags (per D-031): --check-only, --plan-only, --quiet, --help (-h).
|
||||
# Default mode (no flag) = apply.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
CHECK_ONLY=0
|
||||
PLAN_ONLY=0
|
||||
QUIET=0
|
||||
CONTRACT=""
|
||||
|
||||
_print_help() {
|
||||
cat <<EOF
|
||||
usage: run_platform.sh [--check-only|--plan-only|--quiet|--help] [contract.yml]
|
||||
|
||||
--check-only Offline validation: contract -> resolve -> adapter -> validate. No AWS.
|
||||
--plan-only AWS: above + terraform init/validate/plan. No apply.
|
||||
--quiet Suppress streaming output.
|
||||
-h, --help Show this help.
|
||||
|
||||
Default (no flag): full apply path (AWS required).
|
||||
Default contract: contracts/static-assets.yml
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--check-only) CHECK_ONLY=1; shift;;
|
||||
--plan-only) PLAN_ONLY=1; shift;;
|
||||
--quiet) QUIET=1; shift;;
|
||||
-h|--help) _print_help; exit 0;;
|
||||
--*) echo "FAIL: unknown flag $1" >&2; exit 1;;
|
||||
*) CONTRACT="$1"; shift;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -z "$CONTRACT" ] && CONTRACT="contracts/static-assets.yml"
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
WORK="${NOVA_WORK_DIR:-/tmp/nova_platform_run}"
|
||||
TF_DIR="$WORK/tf"
|
||||
rm -rf "$WORK"; mkdir -p "$TF_DIR"
|
||||
|
||||
banner() { [ "$QUIET" -eq 0 ] && echo "=== $* ==="; }
|
||||
|
||||
banner "Step 0: environment check"
|
||||
python3 -c "
|
||||
import sys, json, yaml
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, '.')
|
||||
from core import environment_check
|
||||
contract = yaml.safe_load(open('$CONTRACT'))
|
||||
env = environment_check.check(contract['environment'], Path('core/environments'))
|
||||
print(f'env: {env[\"name\"]} ({env[\"region\"]})')
|
||||
"
|
||||
|
||||
banner "Step 1: validate contract"
|
||||
python3 -c "
|
||||
import json, yaml, jsonschema
|
||||
schema = json.load(open('schemas/contract.schema.json'))
|
||||
contract = yaml.safe_load(open('$CONTRACT'))
|
||||
jsonschema.validate(contract, schema)
|
||||
print('contract valid')
|
||||
"
|
||||
|
||||
banner "Step 2: resolve contract -> stack"
|
||||
python3 core/contract_resolver.py "$CONTRACT" "$WORK/stack.json"
|
||||
python3 -c "import json; s=json.load(open('$WORK/stack.json')); print(f'resources: {len(s[\"resources\"])}')"
|
||||
|
||||
banner "Step 3: adapter compiles stack -> HCL"
|
||||
python3 adapters/terraform/adapter.py "$WORK/stack.json" "$TF_DIR/main.tf"
|
||||
[ "$QUIET" -eq 0 ] && head -20 "$TF_DIR/main.tf"
|
||||
|
||||
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||
python3 -c "
|
||||
from pathlib import Path
|
||||
main = Path('$TF_DIR/main.tf')
|
||||
assert main.exists() and main.stat().st_size > 0, 'main.tf missing or empty'
|
||||
content = main.read_text()
|
||||
assert 'module \"' in content, 'no module blocks'
|
||||
print('output structure valid')
|
||||
"
|
||||
echo "=== PLATFORM CHECK OK ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
banner "Loading AWS credentials"
|
||||
if [ -f .ciagent/.env.secrets ]; then
|
||||
set -a
|
||||
. .ciagent/.env.secrets
|
||||
set +a
|
||||
fi
|
||||
export AWS_ACCESS_KEY_ID="${NOVA_AWS_ACCESS_KEY_ID:-${AWS_ACCESS_KEY_ID:-}}"
|
||||
export AWS_SECRET_ACCESS_KEY="${NOVA_AWS_SECRET_ACCESS_KEY:-${AWS_SECRET_ACCESS_KEY:-}}"
|
||||
export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-us-east-1}"
|
||||
unset NOVA_AWS_ACCESS_KEY_ID NOVA_AWS_SECRET_ACCESS_KEY NOVA_FORGE_TOKEN 2>/dev/null || true
|
||||
[ -z "$AWS_ACCESS_KEY_ID" ] && fail "no AWS credentials (set NOVA_AWS_* in .ciagent/.env.secrets)"
|
||||
|
||||
banner "Step 4: terraform init + validate + plan"
|
||||
( cd "$TF_DIR" && terraform init -backend=false && terraform validate && terraform plan -out=tfplan )
|
||||
|
||||
if [ "$PLAN_ONLY" -eq 1 ]; then
|
||||
echo "=== PLATFORM PLAN OK ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
banner "Step 5: terraform apply"
|
||||
( cd "$TF_DIR" && terraform apply -auto-approve tfplan )
|
||||
echo "=== PLATFORM APPLY OK ==="
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Test scripts/run_ci.sh — REQ-30."""
|
||||
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import ROOT
|
||||
|
||||
pytestmark = pytest.mark.shell
|
||||
|
||||
|
||||
class TestRunCi:
|
||||
def test_exits_zero(self):
|
||||
result = subprocess.run(
|
||||
["bash", str(ROOT / "scripts" / "run_ci.sh")],
|
||||
capture_output=True, text=True, cwd=str(ROOT),
|
||||
)
|
||||
assert result.returncode == 0, f"stderr: {result.stderr}"
|
||||
|
||||
def test_banner(self):
|
||||
result = subprocess.run(
|
||||
["bash", str(ROOT / "scripts" / "run_ci.sh")],
|
||||
capture_output=True, text=True, cwd=str(ROOT),
|
||||
)
|
||||
assert "=== CI PIPELINE OK ===" in result.stdout
|
||||
|
||||
def test_three_stages_message(self):
|
||||
result = subprocess.run(
|
||||
["bash", str(ROOT / "scripts" / "run_ci.sh")],
|
||||
capture_output=True, text=True, cwd=str(ROOT),
|
||||
)
|
||||
assert "3 stages passed: lint, test, check-only" in result.stdout
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Test scripts/run_platform.sh --check-only — REQ-29."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import ROOT
|
||||
|
||||
pytestmark = pytest.mark.shell
|
||||
|
||||
|
||||
class TestRunPlatformCheckOnly:
|
||||
def test_check_only_exits_zero(self):
|
||||
result = subprocess.run(
|
||||
["bash", str(ROOT / "scripts" / "run_platform.sh"),
|
||||
"--check-only", "contracts/static-assets.yml"],
|
||||
capture_output=True, text=True, cwd=str(ROOT),
|
||||
)
|
||||
assert result.returncode == 0, f"stderr: {result.stderr}"
|
||||
|
||||
def test_check_only_banner(self):
|
||||
result = subprocess.run(
|
||||
["bash", str(ROOT / "scripts" / "run_platform.sh"),
|
||||
"--check-only", "contracts/static-assets.yml"],
|
||||
capture_output=True, text=True, cwd=str(ROOT),
|
||||
)
|
||||
assert "=== PLATFORM CHECK OK ===" in result.stdout
|
||||
|
||||
def test_check_only_microservice(self):
|
||||
result = subprocess.run(
|
||||
["bash", str(ROOT / "scripts" / "run_platform.sh"),
|
||||
"--check-only", "contracts/microservice.yml"],
|
||||
capture_output=True, text=True, cwd=str(ROOT),
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "=== PLATFORM CHECK OK ===" in result.stdout
|
||||
Reference in New Issue
Block a user