feat(P4): Nova rebrand — AWS resource migration (REQ-163)

Rename all acdl-* AWS resources → nova-* across terraform (DynamoDB,
Secrets Manager, Lambda, SNS, SG, KMS alias, ECS, ECR, IAM user/policy,
state bucket, ALB, VPC/subnet names). Lambda default table names → nova-*
(D-111). State bucket backend → nova-tfstate (-migrate-state documented).
New docs/NOVA_AWS_MIGRATION.md runbook (staged migration + rollback).
New scripts/migrate_dynamodb_data.py (scan+copy, dry-run default).
acdl-deploy- → nova-deploy- role ARN in deploy workflows. Test fixtures
updated; terraform validate + pytest + run_ci.sh PASS.

---ci---
project: acdl
phase: 4
milestone: v1.15
status: execute
---/ci---
This commit is contained in:
Jon Chery
2026-07-30 01:54:26 +00:00
parent 267df4ad0d
commit 0e6ecae26d
46 changed files with 932 additions and 258 deletions
+5 -8
View File
@@ -43,7 +43,7 @@
# as repository secrets. The platform-managed scheduled pipeline rotates
# the key on a daily cadence. When .env.secrets is used locally instead,
# rotating the key out of band is the consumer's responsibility.
name: acdl-deploy
name: nova-deploy
on:
workflow_call:
@@ -102,11 +102,8 @@ jobs:
- name: Configure AWS credentials (OIDC default + static-key override)
uses: aws-actions/configure-aws-credentials@v4
with:
# TODO(P4, REQ-163): rename the IAM role acdl-deploy- → nova-deploy-.
# The role ARN string is left as acdl-deploy- until P4 (IAM role
# rename territory); only the secret REFERENCES are updated to
# NOVA_* in P2 (G-108 binding).
role-to-assume: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/acdl-deploy-{1}', secrets.NOVA_AWS_ACCOUNT_ID, github.repository_id) || '' }}
# P4 (REQ-163): IAM role renamed acdl-deploy- → nova-deploy-.
role-to-assume: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/nova-deploy-{1}', secrets.NOVA_AWS_ACCOUNT_ID, github.repository_id) || '' }}
aws-region: us-east-1
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
@@ -157,13 +154,13 @@ jobs:
- name: Upload emitted Terraform
uses: actions/upload-artifact@v4
with:
name: acdl-terraform
name: nova-terraform
path: /tmp/acdl_platform_run_v18/tf/*.tf
if-no-files-found: warn
- name: Upload platform log
uses: actions/upload-artifact@v4
with:
name: acdl-platform-log
name: nova-platform-log
path: platform/logs/
if-no-files-found: warn
+5 -8
View File
@@ -43,7 +43,7 @@
# as repository secrets. The platform-managed scheduled pipeline rotates
# the key on a daily cadence. When .env.secrets is used locally instead,
# rotating the key out of band is the consumer's responsibility.
name: acdl-deploy
name: nova-deploy
on:
workflow_call:
@@ -102,11 +102,8 @@ jobs:
- name: Configure AWS credentials (OIDC default + static-key override)
uses: aws-actions/configure-aws-credentials@v4
with:
# TODO(P4, REQ-163): rename the IAM role acdl-deploy- → nova-deploy-.
# The role ARN string is left as acdl-deploy- until P4 (IAM role
# rename territory); only the secret REFERENCES are updated to
# NOVA_* in P2 (G-108 binding).
role-to-assume: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/acdl-deploy-{1}', secrets.NOVA_AWS_ACCOUNT_ID, github.repository_id) || '' }}
# P4 (REQ-163): IAM role renamed acdl-deploy- → nova-deploy-.
role-to-assume: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/nova-deploy-{1}', secrets.NOVA_AWS_ACCOUNT_ID, github.repository_id) || '' }}
aws-region: us-east-1
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
@@ -157,13 +154,13 @@ jobs:
- name: Upload emitted Terraform
uses: actions/upload-artifact@v4
with:
name: acdl-terraform
name: nova-terraform
path: /tmp/acdl_platform_run_v18/tf/*.tf
if-no-files-found: warn
- name: Upload platform log
uses: actions/upload-artifact@v4
with:
name: acdl-platform-log
name: nova-platform-log
path: platform/logs/
if-no-files-found: warn
+7 -7
View File
@@ -2,7 +2,7 @@
Invoked via a Function URL (IAM auth) by consumer pipelines (one-way
communication, D-051). Accepts { consumerRepo, contractId, contract,
environment, action } and writes contracts to DynamoDB table acdl-contracts
environment, action } and writes contracts to DynamoDB table nova-contracts
(PK consumerRepo, SK contractId#submittedAt).
The report_error action (D-055) creates a GitHub issue on the platform repo
@@ -22,10 +22,10 @@ import urllib.parse
import boto3
TABLE_NAME = os.environ.get("CONTRACTS_TABLE", "acdl-contracts")
CHANGE_REQUESTS_TABLE = os.environ.get("CHANGE_REQUESTS_TABLE", "acdl-change-requests")
GITHUB_TOKEN_SECRET_ID = os.environ.get("GITHUB_TOKEN_SECRET_ID", "acdl/github-token")
PLATFORM_REPO = os.environ.get("PLATFORM_REPO", "acdl/acdl")
TABLE_NAME = os.environ.get("CONTRACTS_TABLE", "nova-contracts")
CHANGE_REQUESTS_TABLE = os.environ.get("CHANGE_REQUESTS_TABLE", "nova-change-requests")
GITHUB_TOKEN_SECRET_ID = os.environ.get("GITHUB_TOKEN_SECRET_ID", "nova/github-token")
PLATFORM_REPO = os.environ.get("PLATFORM_REPO", "nova/acdl")
# P1-9: Forge-agnostic API base URL. Defaults to GitHub; set GITHUB_API_BASE
# to a Gitea API root (e.g. https://git.cloudinit.dev/api/v1) for Gitea.
GITHUB_API_BASE = os.environ.get("GITHUB_API_BASE", "https://api.github.com")
@@ -243,7 +243,7 @@ def _validate_caller_identity(event, payload):
error length. The ABAC reliance is documented here: the Function URL IAM
identity does not expose principal tags in the event, so full enforcement
of consumerRepo ownership is at the IAM layer (ABAC via
aws:PrincipalTag/acdl:owner). This function validates format only, not
aws:PrincipalTag/nova:owner). This function validates format only, not
ownership.
"""
identity = event.get("requestContext", {}).get("identity", {})
@@ -279,7 +279,7 @@ def _validate_caller_identity(event, payload):
def _validate_change_request(payload):
"""REQ-93: Validate a change request ID against the CMDB (DynamoDB).
Queries the acdl-change-requests table for the given changeRequestId.
Queries the nova-change-requests table for the given changeRequestId.
Returns the CR details if status is 'approved' and the consumerRepo matches.
Raises ValueError if the CR is not found, not approved, or the repo doesn't match.
"""
+2 -2
View File
@@ -87,7 +87,7 @@ class FlatFileOutbox:
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def write_event(self, event: Dict[str, Any],
outbox_table: str = "acdl-outbox-local",
outbox_table: str = "nova-outbox-local",
region: str = "local") -> Dict[str, Any]:
"""Write an evidence event to the flat-file outbox.
@@ -428,7 +428,7 @@ def run_local_e2e(contract_path: str, repo_root: Optional[Path] = None) -> Dict[
stack = resolve(contract_path, str(root))
stack_name = stack["stack"]["name"]
work = Path(tempfile.mkdtemp(prefix="acdl_local_e2e_"))
work = Path(tempfile.mkdtemp(prefix="nova_local_e2e_"))
tf_dir = work / "tf"
tf_dir.mkdir(exist_ok=True)
adapter.adapt(stack, str(tf_dir))
+3 -3
View File
@@ -1,11 +1,11 @@
"""ACDL Outbox Writer — write an evidence event to the DynamoDB outbox.
"""Nova Outbox Writer — write an evidence event to the DynamoDB outbox.
ARCHITECTURE.md §9: DynamoDB outbox, RPO=0 (synchronous write before
ack). The event is hash-chained (SHA-256 over canonical JSON); the first
event has prev_event_hash="GENESIS". D-P10-3: the spike writes ONE
CONFIDENCE_COMPUTED event.
The outbox table (Phase 08): acdl-outbox, PAY_PER_REQUEST, PK contractId,
The outbox table (Phase 08): nova-outbox, PAY_PER_REQUEST, PK contractId,
SK eventType#eventTs, TTL expire_at = now + 365d (D-044).
CLI: outbox_writer.py <event.json> (uses AWS creds from env)
@@ -20,7 +20,7 @@ import sys
import boto3
OUTBOX_TABLE = "acdl-outbox"
OUTBOX_TABLE = "nova-outbox"
REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
+8 -8
View File
@@ -336,7 +336,7 @@ def _check_live_terraform_plan_microservice() -> Tuple[Status, str]:
dual-read NOVA_* first, ACDL_* fallback per G-106).
Runs in a temp dir; does NOT apply (plan only)."""
import tempfile, os
work = tempfile.mkdtemp(prefix="acdl_regr_live_")
work = tempfile.mkdtemp(prefix="nova_regr_live_")
stack_path = os.path.join(work, "stack.json")
tf_dir = os.path.join(work, "tf")
os.makedirs(tf_dir, exist_ok=True)
@@ -376,7 +376,7 @@ def _check_live_terraform_plan_static_assets() -> Tuple[Status, str]:
"""CAP-014: terraform init+validate+plan against live AWS for the
static-assets stack (CloudFront + WAF + S3)."""
import tempfile, os
work = tempfile.mkdtemp(prefix="acdl_regr_live_sa_")
work = tempfile.mkdtemp(prefix="nova_regr_live_sa_")
stack_path = os.path.join(work, "stack.json")
tf_dir = os.path.join(work, "tf")
os.makedirs(tf_dir, exist_ok=True)
@@ -420,9 +420,9 @@ def _check_dynamodb_outbox_table() -> Tuple[Status, str]:
dyn = boto3.client("dynamodb", region_name=env.get("AWS_DEFAULT_REGION", "us-east-1"),
aws_access_key_id=env.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=env.get("AWS_SECRET_ACCESS_KEY"))
r = dyn.describe_table(TableName="acdl-outbox")
r = dyn.describe_table(TableName="nova-outbox")
count = r["Table"].get("ItemCount", "unknown")
return "Verified", f"acdl-outbox exists, item_count={count}"
return "Verified", f"nova-outbox exists, item_count={count}"
except Exception as e:
return "Decayed", f"describe_table failed: {type(e).__name__}: {str(e)[:150]}"
@@ -436,7 +436,7 @@ def _check_s3_state_bucket() -> Tuple[Status, str]:
aws_access_key_id=env.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=env.get("AWS_SECRET_ACCESS_KEY"))
account_id = _envhelper.get_env("AWS_ACCOUNT_ID", "581513795199")
state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
state_bucket = f"nova-tfstate-{account_id}-us-east-1"
s3.head_bucket(Bucket=state_bucket)
r = s3.list_objects_v2(Bucket=state_bucket, MaxKeys=5)
keys = [o["Key"] for o in r.get("Contents", [])]
@@ -508,7 +508,7 @@ def _check_lifecycle_l2_module(module: str) -> Tuple[Status, str]:
def _check_cap_017_dynamodb() -> Tuple[Status, str]:
"""CAP-017: DynamoDB acdl-contracts table. Evidence = L1 rds module
"""CAP-017: DynamoDB nova-contracts table. Evidence = L1 rds module
lifecycle pipeline green (terraform validate + contracts resolve).
The DynamoDB table is created via the microservice stack (L2 lifecycle).
"""
@@ -523,7 +523,7 @@ def _check_cap_018_lambda() -> Tuple[Status, str]:
"python3", "-c",
"from core.local_emulators import LocalLambdaStub, FlatFileOutbox; "
"import tempfile; "
"stub = LocalLambdaStub(outbox=FlatFileOutbox(tempfile.mkdtemp(prefix='acdl_stub_'))); "
"stub = LocalLambdaStub(outbox=FlatFileOutbox(tempfile.mkdtemp(prefix='nova_stub_'))); "
"print('LocalLambdaStub instantiates OK')",
])
if rc != 0:
@@ -592,7 +592,7 @@ CAPABILITY_REGISTRY: List[Tuple[str, str, str, Callable[[], Tuple[Status, str]]]
_check_dynamodb_outbox_table),
("CAP-016", "S3 state bucket exists + readable (live AWS)", "live-aws",
_check_s3_state_bucket),
("CAP-017", "DynamoDB acdl-contracts table (lifecycle pipeline evidence)", "lifecycle-pipeline",
("CAP-017", "DynamoDB nova-contracts table (lifecycle pipeline evidence)", "lifecycle-pipeline",
_check_cap_017_dynamodb),
("CAP-018", "Lambda contract-ingestor (local stub + lifecycle evidence)", "lifecycle-pipeline",
_check_cap_018_lambda),
+270
View File
@@ -0,0 +1,270 @@
# Nova AWS Resource Migration Runbook (REQ-163, P4)
> **Milestone:** v1.15-Nova (Wave 4, P4). Renames every `acdl-*` AWS
> resource name → `nova-*` via Terraform. This is the heaviest Terraform
> phase of the rebrand and requires a **maintenance window**.
>
> **Plan-validated only.** Per A1, `NOVA_LIFECYCLE_MODE` defaults to
> `plan` (no live AWS mutation from CI). `terraform validate` passes; the
> live apply steps below are executed by a platform operator during the
> scheduled maintenance window. Each step has a verification + rollback.
## Scope (renamed resources)
| AWS resource | Before | After | Strategy |
|---|---|---|---|
| KMS alias | `alias/acdl-platform` | `alias/nova-platform` | cheap rename |
| SNS topic | `acdl-sod-halt` | `nova-sod-halt` | recreate |
| Security group | `acdl-ecs-sg` | `nova-ecs-sg` | recreate |
| Lambda (role/policy/function) | `acdl-contract-ingestor` | `nova-contract-ingestor` | recreate |
| DynamoDB contracts | `acdl-contracts` | `nova-contracts` | scan + copy |
| DynamoDB change-requests | `acdl-change-requests` | `nova-change-requests` | scan + copy |
| Secrets Manager secret | `acdl/github-token` | `nova/github-token` | recreate + re-store |
| ECR repo | `acdl-microservice` | `nova-microservice` | re-push |
| ECS cluster/service/task/role | `acdl-microservice` | `nova-microservice` | recreate |
| IAM user + policy | `acdl-spike-runner` (+ `-policy`) | `nova-spike-runner` (+ `-policy`) | re-bootstrap |
| IAM act-runner role | `acdl-act-runner-role` | `nova-act-runner-role` | re-bootstrap |
| IAM deploy role | `acdl-deploy-<repo>` | `nova-deploy-<repo>` | re-bootstrap |
| S3 state bucket | `acdl-tfstate-581513795199-us-east-1` | `nova-tfstate-581513795199-us-east-1` | `-migrate-state` |
| DynamoDB outbox | `acdl-outbox` | `nova-outbox` | scan + copy |
| Platform VPC/subnet/IGW/RT | `acdl-shared*` | `nova-shared*` | recreate (brief downtime) |
| CI VPC/subnet/SG/cluster | `acdl-ci-*` | `nova-ci-*` | recreate (CI-only) |
| ALB name prefix | `acdl-alb` | `nova-alb` | recreate (brief downtime, LAST) |
## Migration ordering (binding)
Order: **KMS alias → SNS/SG → Lambda → DynamoDB → ECR → IAM → state bucket → ALB**.
Each step is independently rollback-able. The ALB is last because it
requires the briefest downtime window.
---
## Pre-flight
1. **Announce the maintenance window** (consumers are notified via the
P1 migration guide `docs/NOVA_MIGRATION.md`).
2. **Back up state** for every stack (see §State bucket — back up the
state JSON *before* `-migrate-state`).
3. Confirm `NOVA_LIFECYCLE_MODE=plan` (default) so CI does not mutate
AWS during the window.
4. Confirm the new `nova-*` destination tables/repos will be created by
the same Terraform apply (no manual pre-creation needed).
## Step 1 — KMS alias (`alias/acdl-platform` → `alias/nova-platform`)
- **Command (in `terraform/platform/`):**
```bash
terraform init -upgrade
terraform apply -replace=aws_kms_alias.nova_platform
```
(Terraform destroys the old alias + creates the new one — aliases are
cheap; the underlying key ID is unchanged.)
- **Verify:** `aws kms list-aliases --query 'Aliases[?AliasName==`alias/nova-platform`]'` returns the new alias; `alias/acdl-platform` is gone.
- **Rollback:** `terraform apply -replace=aws_kms_alias.nova_platform` against the prior revision (re-creates `alias/acdl-platform`). Resources encrypted by the key are unaffected (key ID unchanged).
## Step 2 — SNS topic + Security group (recreate)
- **Command:** `terraform apply` in `terraform/platform/`.
- SNS `acdl-sod-halt` → `nova-sod-halt` (the topic ARN changes; update `NOVA_SOD_HALT_TOPIC_ARN` wherever it is set).
- SG `acdl-ecs-sg` → `nova-ecs-sg` (the security group is re-attached to running ECS tasks; brief task restart).
- **Verify:** `aws sns list-topics` shows `nova-sod-halt`; `aws ec2 describe-security-groups` shows `nova-ecs-sg`.
- **Rollback:** `terraform apply` the prior revision re-creates the `acdl-*` names. The SNS topic has no message backlog (halt artifacts are fire-and-forget); the SG drift resolves on next task deploy.
## Step 3 — Lambda (recreate)
- **Command:** `terraform apply` in `terraform/platform/`.
- Lambda function `acdl-contract-ingestor` → `nova-contract-ingestor`.
- Execution role `acdl-contract-ingestor-role` → `nova-contract-ingestor-role`.
- Inline policy `acdl-contract-ingestor-policy` → `nova-contract-ingestor-policy`.
- The Lambda env vars (`CONTRACTS_TABLE`, `GITHUB_TOKEN_SECRET_ID`) now resolve to `nova-*` defaults.
- **Verify:** `aws lambda list-functions` shows `nova-contract-ingestor`; the Function URL returns 200 on a SigV4-signed invoke. The `consumer_invoke_policy.json` rendered output (Terraform `consumer_invoke_policy_rendered`) now references `function:nova-contract-ingestor` — re-distribute to consumer deploy roles.
- **Rollback:** `terraform apply` the prior revision re-creates `acdl-contract-ingestor`. Consumer deploy roles must point back at the old Function ARN (re-distribute the prior `consumer_invoke_policy.json`).
## Step 4 — DynamoDB (scan + copy)
DynamoDB table names are immutable post-creation, so the migration is a
**scan + copy** (not a rename). The new `nova-*` tables are created by
the same Terraform apply (Step 3). The data-migration script copies
every item and verifies row counts.
- **Command (from repo root):**
```bash
# Dry-run first (no writes):
python3 scripts/migrate_dynamodb_data.py
# Execute the copy:
python3 scripts/migrate_dynamodb_data.py --apply
# A single table:
python3 scripts/migrate_dynamodb_data.py --table contracts --apply
```
The script scans `acdl-contracts` → copies to `nova-contracts`, and
`acdl-change-requests` → `nova-change-requests`, then verifies the
destination row count == source row count (re-scan, not
`DescribeTable.ItemCount` which lags ~6h).
- **Verify:**
```bash
# Row counts must match (printed by the script). Manual cross-check:
aws dynamodb scan --table-name nova-contracts --select COUNT
aws dynamodb scan --table-name acdl-contracts --select COUNT
```
Then **point consumers at the new tables** (the Lambda already reads
`nova-*` defaults; any direct DynamoDB consumers update their env).
- **Keep the old tables** (`acdl-contracts`, `acdl-change-requests`)
until consumers are verified reading from `nova-*`. **Deletion is a
manual post-verification step:**
```bash
aws dynamodb delete-table --table-name acdl-contracts
aws dynamodb delete-table --table-name acdl-change-requests
```
Only delete after a full soak period confirms `nova-*` reads succeed.
- **Rollback:** Re-point consumers at `acdl-*` (the old tables are
retained). The copy is additive (no data loss). To roll back a partial
copy, re-run `--apply` (idempotent — `PutItem` overwrites).
### Outbox table (`acdl-outbox` → `nova-outbox`)
The evidence outbox table follows the same scan+copy pattern (it is
created by `terraform/bootstrap/create_state_backend.py`).
- **Command:** `python3 scripts/migrate_dynamodb_data.py --source acdl-outbox --dest nova-outbox --apply`
- The `core/outbox_writer.py` default + `core/regression_verify.py`
CAP-015 probe now reference `nova-outbox` (P4 updated both). The
regression gate's live-AWS CAP-015 will return `Verified` once the
`nova-outbox` table exists live; until then it is `Decayed` (the gate
is re-run at milestone complete after the live migration).
## Step 5 — ECR (re-push)
- **Command:** `terraform apply` in `terraform/microservice/` creates
the new `nova-microservice` ECR repo. Re-push the image:
```bash
python3 scripts/push_consumer_image.py # creates nova-microservice + prints docker tag/push
```
(The script's `ECR_REPO_NAME` is now `nova-microservice`.)
- **Verify:** `aws ecr describe-repositories` shows `nova-microservice`; `docker pull <acct>.dkr.ecr.us-east-1.amazonaws.com/nova-microservice:latest` succeeds.
- **Rollback:** The old `acdl-microservice` repo is retained until the
soak passes. Re-push to it if a rollback is needed. Delete it manually:
`aws ecr delete-repository --repository-name acdl-microservice --force`.
## Step 6 — IAM (re-bootstrap)
- **Command:**
```bash
export NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID="<root key>"
export NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY="<root secret>"
python3 terraform/bootstrap/create_state_backend.py # creates nova-outbox (idempotent)
python3 terraform/bootstrap/create_iam_user.py # creates nova-spike-runner
python3 terraform/bootstrap/apply_iam_baseline.py # creates nova-spike-runner-policy + nova-act-runner-role
bash scripts/rotate_spike_key.sh # rotates the nova-spike-runner key
```
The deploy role `acdl-deploy-<repo>` → `nova-deploy-<repo>` is
created by the bootstrap (the deploy workflow
`.gitea/.github/workflows/deploy.yml` now references
`role/nova-deploy-{1}`).
- **Verify:** `aws iam get-user --user-name nova-spike-runner`;
`aws iam list-attached-user-policies --user-name nova-spike-runner`
shows `nova-spike-runner-policy`;
`aws iam get-role --role-name nova-act-runner-role`.
- **Rollback:** Re-run the prior bootstrap scripts (they create
`acdl-spike-runner` + `acdl-act-runner-role`). The deploy workflow's
`role-to-assume` must be reverted to `acdl-deploy-` (prior revision).
## Step 7 — State bucket (`acdl-tfstate-*` → `nova-tfstate-*`, `-migrate-state`)
The S3 state backend is renamed. Terraform's `-migrate-state` copies the
state objects to the new bucket. **Back up the state JSON first.**
- **Back up state (per stack):**
```bash
for stack in platform microservice ci-vpc; do
aws s3 cp s3://acdl-tfstate-581513795199-us-east-1/$stack/terraform.tfstate \
./backup-$stack.tfstate
done
```
- **Command (per stack):** the backend config in each
`terraform/*/terraform.tf` now points at `nova-tfstate-...`.
```bash
cd terraform/platform
terraform init -migrate-state # copies state acdl-tfstate → nova-tfstate
cd ../microservice
terraform init -migrate-state
cd ../ci-vpc
terraform init -migrate-state
```
- **Verify:** `aws s3 ls s3://nova-tfstate-581513795199-us-east-1/`
shows the state keys; `terraform state list` in each dir lists the
expected resources.
- **Rollback:** Point the backend back at `acdl-tfstate-*` and re-run
`terraform init -migrate-state` (restores from the backup bucket). The
old `acdl-tfstate-*` bucket is retained until the soak passes. Delete
it manually:
`aws s3 rb s3://acdl-tfstate-581513795199-us-east-1 --force`.
## Step 8 — ALB (recreate, brief downtime, LAST)
The ALB is last because its recreation requires the briefest downtime
window (the ECS service is re-attached to the new target group).
- **Command:** `terraform apply` in `terraform/microservice/`. The ALB
`acdl-microservice` / `acdl-alb` → `nova-microservice` / `nova-alb`.
- **Verify:** `aws elbv2 describe-load-balancers` shows the new ALB;
`curl http://<new-alb-dns>/` returns 200.
- **Rollback:** `terraform apply` the prior revision re-creates the
`acdl-*` ALB (brief downtime again). The old ALB DNS is retained until
consumers are re-pointed.
---
## Post-migration
1. **Soak:** run consumers against `nova-*` for a full verification
window (deploy a test contract end-to-end).
2. **Delete old resources** (manual, only after soak):
- DynamoDB: `acdl-contracts`, `acdl-change-requests`, `acdl-outbox`
- ECR: `acdl-microservice`
- IAM: `acdl-spike-runner` (+ policy), `acdl-act-runner-role`,
`acdl-deploy-<repo>`
- S3: `acdl-tfstate-581513795199-us-east-1`
- SNS: `acdl-sod-halt`
- SG: `acdl-ecs-sg`
- Secrets Manager: `acdl/github-token`
- KMS alias: `alias/acdl-platform`
- ALB: `acdl-alb` / `acdl-microservice`
3. **Regression gate:** re-run `bash scripts/run_regression.sh`. The
live-AWS CAP-013..016 probes should return `Verified` (the `nova-*`
tables + state bucket exist). CAP-015 (outbox) flips from `Decayed`
→ `Verified` once `nova-outbox` is live.
## What P5 owns (not P4)
- **Remove dual-read fallback:** `core/env.py` `get_env()` drops the
`ACDL_*` fallback; shell scripts drop `:-$ACDL_X`. P4 keeps the
dual-read (deployments don't break mid-window).
- **`nova_tagging.py` hard-fail on `acdl:*`:** P3 set hard mode (no
`acdl:*`-only tags); P5 tightens to fail on any `acdl:*` presence. P4
leaves P3's behavior.
- **Delete `ACDL_*` Gitea secrets:** the `NOVA_*` aliases created in P2
are now the only source.
- **Finalize `docs/NOVA_MIGRATION.md`:** mark the migration complete
(cutoff passed).
- **Milestone ship:** tag `v1.15.4`, merge to `main`, Gitea release.
## Files touched in P4
- `terraform/platform/main.tf`, `terraform/microservice/main.tf`,
`terraform/ci-vpc/main.tf` — resource renames + backend bucket.
- `terraform/{platform,microservice,ci-vpc}/terraform.tf` — state bucket.
- `terraform/platform/consumer_invoke_policy.json` — Lambda ARN.
- `terraform/bootstrap/{create_state_backend,create_iam_user,apply_iam_baseline}.py`,
`spike_runner_policy.json`, `.bootstrap_state.json`, `README.md` —
IAM/outbox/state-bucket renames.
- `modules/l1/*/terraform/**` + `modules/l1/alb/instance.json` — L1
resource-name defaults.
- `modules/l2/microservice/composition.json` — `nova-app-role` default.
- `core/lambda/contract_ingestor.py` — default table names (D-111).
- `core/outbox_writer.py`, `core/regression_verify.py`,
`core/local_emulators.py` — outbox table consistency (cross-territory,
minimal).
- `.gitea/workflows/deploy.yml` + `.github/workflows/deploy.yml` —
`nova-deploy-` role ARN + artifact names.
- `scripts/migrate_dynamodb_data.py` (NEW), `scripts/rotate_spike_key.sh`,
`scripts/push_consumer_image.py`.
- `tests/**` — fixtures updated to assert `nova-*`.
+2 -2
View File
@@ -11,7 +11,7 @@
"type": "aws:elbv2:loadbalancer",
"module": "alb@1.0.0",
"inputs": {
"name": "acdl-alb",
"name": "nova-alb",
"subnets": "subnet-12345",
"security_group": "sg-12345",
"region": "us-east-1"
@@ -27,7 +27,7 @@
"type": "aws:elbv2:targetgroup",
"module": "alb@1.0.0",
"inputs": {
"name": "acdl-alb",
"name": "nova-alb",
"port": 80,
"protocol": "HTTP",
"region": "us-east-1"
+4 -4
View File
@@ -1,7 +1,7 @@
locals {
# OAC defaults (adapter previously hardcoded these).
oac_name = "acdl-oac"
oac_origin_type = "s3"
oac_signing_behavior = "always"
oac_signing_protocol = "sigv4"
oac_name = "nova-oac"
oac_origin_type = "s3"
oac_signing_behavior = "always"
oac_signing_protocol = "sigv4"
}
@@ -1,7 +1,7 @@
variable "name" {
type = string
description = "ECS cluster name."
default = "acdl-cluster"
default = "nova-cluster"
}
variable "region" {
+3 -3
View File
@@ -8,15 +8,15 @@ resource "aws_ecs_task_definition" "this" {
}
resource "aws_ecs_service" "this" {
name = "acdl-microservice"
name = "nova-microservice"
cluster = var.cluster_arn
task_definition = aws_ecs_task_definition.this.arn
desired_count = var.desired_count
launch_type = var.launch_type
network_configuration {
subnets = local.subnet_list
security_groups = local.security_groups
subnets = local.subnet_list
security_groups = local.security_groups
assign_public_ip = var.launch_type == "FARGATE"
}
+1 -1
View File
@@ -1,7 +1,7 @@
variable "role_name" {
type = string
description = "The IAM role name."
default = "acdl-microservice-role"
default = "nova-microservice-role"
}
variable "assume_role_policy" {
+1 -1
View File
@@ -1,3 +1,3 @@
locals {
alias_name = "alias/acdl-ci-kms"
alias_name = "alias/nova-ci-kms"
}
+1 -1
View File
@@ -1,6 +1,6 @@
resource "aws_db_subnet_group" "this" {
count = var.subnet_ids != "" ? 1 : 0
name = "acdl-ci-rds-subnet-group"
name = "nova-ci-rds-subnet-group"
subnet_ids = split(",", var.subnet_ids)
}
+1 -1
View File
@@ -10,5 +10,5 @@ output "bucket_name" {
output "bucket_regional_domain_name" {
value = aws_s3_bucket.this.bucket_regional_domain_name
description = "The bucket regional domain name (e.g. acdl-spike-bucket.s3.us-east-1.amazonaws.com)."
description = "The bucket regional domain name (e.g. nova-spike-bucket.s3.us-east-1.amazonaws.com)."
}
+2 -2
View File
@@ -1,5 +1,5 @@
resource "aws_ecs_task_definition" "uptime" {
family = "acdl-uptime"
family = "nova-uptime"
cpu = tostring(var.cpu)
memory = tostring(var.memory)
requires_compatibilities = ["FARGATE"]
@@ -8,7 +8,7 @@ resource "aws_ecs_task_definition" "uptime" {
}
resource "aws_ecs_service" "uptime" {
name = "acdl-uptime"
name = "nova-uptime"
cluster = local.cluster_ref
task_definition = aws_ecs_task_definition.uptime.arn
desired_count = var.feature_flag_enabled ? 1 : 0
+1 -1
View File
@@ -1,7 +1,7 @@
locals {
cidr_block = var.cidr != null ? var.cidr : "10.0.0.0/16"
az_list = split(",", var.azs)
name_tag = var.name != null ? var.name : "acdl-vpc"
name_tag = var.name != null ? var.name : "nova-vpc"
# Derive subnet CIDRs from the VPC CIDR
subnet_cidrs = [
+1 -1
View File
@@ -13,7 +13,7 @@ variable "azs" {
variable "name" {
type = string
description = "Name tag for the VPC and child resources."
default = "acdl-vpc"
default = "nova-vpc"
}
variable "region" {
+1 -1
View File
@@ -15,7 +15,7 @@ resource "aws_wafv2_web_acl" "this" {
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "acdl-waf-metrics"
metric_name = "nova-waf-metrics"
sampled_requests_enabled = true
}
+1 -1
View File
@@ -1,7 +1,7 @@
variable "name" {
type = string
description = "WAF Web ACL name."
default = "acdl-waf"
default = "nova-waf"
}
variable "scope" {
+1 -1
View File
@@ -18,7 +18,7 @@
"wires": [
{"from": "contract.inputs.name", "to": "alb.inputs.name", "default": "app"},
{"from": "contract.inputs.name", "to": "ecr.inputs.name", "default": "app-repo"},
{"from": "contract.inputs.name", "to": "roles.inputs.role_name", "default": "acdl-app-role"},
{"from": "contract.inputs.name", "to": "roles.inputs.role_name", "default": "nova-app-role"},
{"from": "contract.inputs.region", "to": "cluster.inputs.region"},
{"from": "contract.inputs.region", "to": "ecr.inputs.region"},
{"from": "contract.inputs.region", "to": "roles.inputs.region"},
+1 -1
View File
@@ -17,7 +17,7 @@
#
# Validated against schemas/deploy-pipeline.schema.json.
name: acdl-deploy
name: nova-deploy
environment: dev
triggers:
push: [main]
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Migrate DynamoDB table data from acdl-* → nova-* (REQ-163, P4).
The Nova rebrand (v1.15) renames the platform DynamoDB tables:
- ``acdl-contracts`` → ``nova-contracts``
- ``acdl-change-requests`` → ``nova-change-requests``
DynamoDB table names are immutable post-creation, so the migration is a
**scan + copy**: every item in the old table is written to the new table
(preserving the full item shape — PK, SK, and all attributes). Row counts
are verified to match post-copy. The old tables are **kept** until the
operator verifies the copy; deletion is a manual post-verification step
documented in ``docs/NOVA_AWS_MIGRATION.md`` (runbook).
Design:
- **Dry-run by default.** Prints the planned copy operations + counts
without touching AWS. Pass ``--apply`` to execute the copy.
- **Idempotent.** Re-running against an already-migrated item is a
no-op (``PutItem`` overwrites in place; the copy is re-run but the
row counts still match). The script does NOT delete the old tables
(deletion is a manual runbook step).
- **Item-mapping logic is pure + unit-tested** (see
``tests/test_migrate_dynamodb_data.py``); the AWS I/O is thin boto3
glue around ``map_item()`` + ``scan_all()``.
- **boto3 lazy import.** The module is importable + unit-testable
without AWS credentials (the client is constructed inside ``run()``).
Usage:
python3 scripts/migrate_dynamodb_data.py # dry-run (default)
python3 scripts/migrate_dynamodb_data.py --apply # execute the copy
python3 scripts/migrate_dynamodb_data.py --region us-east-1 --apply
python3 scripts/migrate_dynamodb_data.py --table contracts --apply
python3 scripts/migrate_dynamodb_data.py --source acdl-contracts --dest nova-contracts --apply
Pre-requisites (live AWS, documented in the runbook):
- The nova-* destination tables must already exist (created via
``terraform/platform/main.tf``).
- AWS credentials in env with scan+PutItem on both old + new tables.
"""
from __future__ import annotations
import argparse
import copy
import sys
from typing import Dict, List, Optional, Tuple
try:
import boto3
except ImportError: # pragma: no cover - boto3 is a test dep
boto3 = None # type: ignore
# ---------------------------------------------------------------------------
# Default table-pair mapping (REQ-163)
# ---------------------------------------------------------------------------
DEFAULT_TABLE_PAIRS: List[Tuple[str, str]] = [
("acdl-contracts", "nova-contracts"),
("acdl-change-requests", "nova-change-requests"),
]
# ---------------------------------------------------------------------------
# Pure item-mapping logic (unit-tested)
# ---------------------------------------------------------------------------
def map_item(item: Dict) -> Dict:
"""Return a copy of a DynamoDB item suitable for PutItem into the new table.
DynamoDB items returned by ``scan``/``get_item`` are in the typed-attribute
shape (``{"attr": {"S": "value"}, ...}``). The copy is identity-preserving:
the item is written verbatim to the destination table so the PK/SK + every
attribute land identically. No key-rewrite is needed because the old + new
tables share the same key schema (PK ``consumerRepo``, SK
``contractId#submittedAt`` for contracts; PK ``changeRequestId``, SK
``submittedAt`` for change-requests).
The mapping is a deep copy so callers can mutate the result without
aliasing the scanned item (DynamoDB items nest typed-attribute dicts,
e.g. ``{"attr": {"S": "value"}}``). ``map_item`` is pure +
side-effect-free.
Examples:
>>> map_item({"consumerRepo": {"S": "acdl/c"}, "k": {"N": "1"}})
{'consumerRepo': {'S': 'acdl/c'}, 'k': {'N': '1'}}
>>> map_item({}) == {}
True
"""
return copy.deepcopy(item)
def table_pair_for(name: str, pairs: Optional[List[Tuple[str, str]]] = None) -> Tuple[str, str]:
"""Resolve a logical table name (``contracts`` / ``change-requests``) or a
literal source-table name to its ``(source, dest)`` pair.
Examples:
>>> table_pair_for("contracts")
('acdl-contracts', 'nova-contracts')
>>> table_pair_for("change-requests")
('acdl-change-requests', 'nova-change-requests')
>>> table_pair_for("acdl-contracts")
('acdl-contracts', 'nova-contracts')
>>> table_pair_for("nova-contracts")
('nova-contracts', 'nova-contracts')
"""
table = pairs if pairs is not None else DEFAULT_TABLE_PAIRS
aliases = {
"contracts": ("acdl-contracts", "nova-contracts"),
"change-requests": ("acdl-change-requests", "nova-change-requests"),
}
if name in aliases:
return aliases[name]
for src, dst in table:
if name == src:
return (src, dst)
if name == dst:
return (src, dst)
raise ValueError(
f"unknown table {name!r}; expected one of: contracts, change-requests, "
f"or a literal source name from {table!r}"
)
# ---------------------------------------------------------------------------
# Thin AWS I/O glue (constructed lazily inside run)
# ---------------------------------------------------------------------------
def scan_all(client, table_name: str) -> List[Dict]:
"""Scan every item in ``table_name`` (paginates through all segments).
Returns the full list of items (typed-attribute shape). Uses
``table.scan()`` with pagination on ``LastEvaluatedKey``.
"""
items: List[Dict] = []
last_key: Optional[Dict] = None
while True:
kwargs: Dict = {"TableName": table_name}
if last_key is not None:
kwargs["ExclusiveStartKey"] = last_key
resp = client.scan(**kwargs)
items.extend(resp.get("Items", []))
last_key = resp.get("LastEvaluatedKey")
if not last_key:
break
return items
def copy_items(client, source_table: str, dest_table: str, items: List[Dict]) -> int:
"""PutItem every mapped item into ``dest_table``. Returns the count written."""
written = 0
for item in items:
client.put_item(TableName=dest_table, Item=map_item(item))
written += 1
return written
def count_items(client, table_name: str) -> int:
"""Return the approximate item count via ``DescribeTable``.
Uses ``Table.ItemCount`` (updated ~6hourly by AWS) for a fast count; for
exact verification prefer ``len(scan_all(...))`` (the runbook documents
both — scan is the source of truth for row-count verification).
"""
resp = client.describe_table(TableName=table_name)
return int(resp["Table"].get("ItemCount", 0))
# ---------------------------------------------------------------------------
# Driver
# ---------------------------------------------------------------------------
def run(args: argparse.Namespace) -> int:
pairs = DEFAULT_TABLE_PAIRS
if args.source and args.dest:
pairs = [(args.source, args.dest)]
elif args.table:
pairs = [table_pair_for(args.table)]
region = args.region
if boto3 is None:
print("FAIL: boto3 is not installed (pip install boto3)", file=sys.stderr)
return 2
client = boto3.client("dynamodb", region_name=region)
mode = "APPLY" if args.apply else "DRY-RUN"
overall_rc = 0
for source, dest in pairs:
print(f"\n=== {mode}: {source}{dest} (region {region}) ===")
try:
client.describe_table(TableName=source)
except Exception as e:
print(f" FAIL: source table {source!r} not describable: "
f"{type(e).__name__}: {e}", file=sys.stderr)
overall_rc = 1
continue
try:
client.describe_table(TableName=dest)
except Exception as e:
print(f" FAIL: dest table {dest!r} not describable (create it via "
f"terraform first): {type(e).__name__}: {e}", file=sys.stderr)
overall_rc = 1
continue
items = scan_all(client, source)
src_count = len(items)
print(f" scanned {src_count} item(s) from {source}")
if not args.apply:
print(f" [dry-run] would PutItem {src_count} item(s) into {dest}")
print(f" [dry-run] would verify {dest} row count == {src_count}")
print(f" [dry-run] old table {source} is NOT deleted (manual runbook step)")
continue
written = copy_items(client, source, dest, items)
print(f" copied {written} item(s) → {dest}")
# Verify by re-scanning the destination (source of truth, not DescribeTable).
dest_items = scan_all(client, dest)
dest_count = len(dest_items)
if dest_count != src_count:
print(f" WARNING: row-count mismatch — source={src_count}, "
f"dest={dest_count}. Investigate before deleting {source}.",
file=sys.stderr)
overall_rc = 1
else:
print(f" VERIFIED: {dest} row count ({dest_count}) == source ({src_count})")
print(f" Old table {source} is KEPT. Delete it manually only after "
f"verifying consumers read from {dest} (runbook step).")
if overall_rc == 0:
print(f"\n=== {mode} complete ({len(pairs)} pair(s)) ===")
else:
print(f"\n=== {mode} complete with FAILURES ===", file=sys.stderr)
return overall_rc
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="Migrate DynamoDB data acdl-* → nova-* (REQ-163, P4).",
)
p.add_argument("--apply", action="store_true",
help="Execute the copy (default: dry-run, no AWS writes).")
p.add_argument("--region", default="us-east-1",
help="AWS region (default: us-east-1).")
p.add_argument("--table", default=None,
help="Migrate a single logical table: 'contracts' or "
"'change-requests' (default: both).")
p.add_argument("--source", default=None,
help="Override the source table name (paired with --dest).")
p.add_argument("--dest", default=None,
help="Override the destination table name (paired with --source).")
return p
def main(argv: Optional[List[str]] = None) -> int:
args = build_parser().parse_args(argv)
return run(args)
if __name__ == "__main__":
sys.exit(main())
+4 -4
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
"""ACDL Phase 15 — push the consumer microservice Docker image to ECR.
"""Nova Phase 15 — push the consumer microservice Docker image to ECR.
Steps performed by this script:
1. Load AWS creds from /root/acdl/.env.secrets
(NOVA_AWS_ACCESS_KEY_ID, NOVA_AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION;
dual-read ACDL_* fallback until P5).
2. Create the ECR repo `acdl-microservice` if it doesn't exist
2. Create the ECR repo `nova-microservice` if it doesn't exist
(ecr:DescribeRepositories / ecr:CreateRepository). Region: us-east-1.
3. Get the ECR login password (ecr:GetAuthorizationToken) and run
`docker login` with it.
@@ -40,7 +40,7 @@ REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
ENV_FILE = REPO_ROOT / ".env.secrets"
AWS_ACCOUNT_ID = env.get_env("AWS_ACCOUNT_ID", "581513795199")
AWS_REGION = "us-east-1"
ECR_REPO_NAME = "acdl-microservice"
ECR_REPO_NAME = "nova-microservice"
IMAGE_TAG = "latest"
@@ -131,7 +131,7 @@ def main():
full_tag = f"{repo_uri}:{IMAGE_TAG}"
print("")
print("=== NEXT: run these commands in the shell to tag + push ===")
print(f"docker tag acdl-microservice:latest {full_tag}")
print(f"docker tag nova-microservice:latest {full_tag}")
print(f"docker push {full_tag}")
print("")
print(f"ECR_IMAGE={full_tag}")
+6 -7
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
# scripts/rotate_spike_key.sh - rotate the acdl-spike-runner IAM access key.
# scripts/rotate_spike_key.sh - rotate the nova-spike-runner IAM access key.
#
# Uses the bootstrap root key (NOVA_BOOTSTRAP_AWS_*, ACDL_BOOTSTRAP_AWS_*
# fallback) from the env to:
# 1. List acdl-spike-runner's access keys.
# 1. List nova-spike-runner's access keys.
# 2. Create a new key.
# 3. Deactivate + delete the old key(s).
# 4. Write the new key to gitignored .env.secrets (chmod 600).
@@ -14,9 +14,8 @@
#
# Spike scope (D-039): the spike user key is per-run-rotated; real OIDC is
# v1.2 (blocked on go-gitea/gitea#36988).
# Nova rebrand (P2): writes NOVA_* keys; ACDL_* bootstrap fallback kept
# until P5 (the AWS user/role rename acdl-spike-runner → nova-spike-runner
# is P4 territory — left unchanged here).
# Nova rebrand (P4, REQ-163): IAM user renamed acdl-spike-runner →
# nova-spike-runner.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
@@ -28,7 +27,7 @@ fail() { echo "FAIL: $*" >&2; exit 1; }
: "${NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID:-${ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID:?set NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID (or ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID) to the root key}}"
: "${NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY:-${ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY:?set NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY (or ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY) to the root key}}"
REGION="${AWS_DEFAULT_REGION:-us-east-1}"
USER_NAME="acdl-spike-runner"
USER_NAME="nova-spike-runner"
# Confirm .env.secrets is gitignored before writing to it.
git check-ignore -q "$ENV_FILE" || fail "$ENV_FILE is not gitignored — refusing to write the key"
@@ -40,7 +39,7 @@ import json
import boto3
region = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
user = "acdl-spike-runner"
user = "nova-spike-runner"
env_file = os.path.join(os.getcwd(), ".env.secrets")
# Dual-read bootstrap creds: NOVA_* preferred, ACDL_* fallback (G-106, removed in P5).
+1
View File
@@ -54,6 +54,7 @@ python3 -m py_compile \
adapters/wiz/wiz_adapter.py \
adapters/kyverno/kyverno_adapter.py \
scripts/push_consumer_image.py \
scripts/migrate_dynamodb_data.py \
|| fail "lint: py_compile failed"
echo "lint: OK"
+20 -20
View File
@@ -1,21 +1,21 @@
# ACDL v1.1 Spike — AWS Bootstrap Runbook
# Nova v1.1 Spike — AWS Bootstrap Runbook
Phase 08 bootstraps the AWS engine for the v1.1 spike. It uses the
**root account credential for account 581513795199 exactly once**, then
closes D-034 by having the user manually rotate the root key afterward.
> **Spike scope (D-039):** the spike uses a per-run-rotated IAM *user* key
> (`acdl-spike-runner`), NOT OIDC. Real OIDC federation is deferred to
> (`nova-spike-runner`), NOT OIDC. Real OIDC federation is deferred to
> v1.2 (blocked on go-gitea/gitea#36988 — Gitea Actions does not support
> `id-token: write`). The `acdl-spike-runner` user + its key are deleted
> `id-token: write`). The `nova-spike-runner` user + its key are deleted
> in v1.2 cleanup when the OIDC role lands.
## Steps
1. **Set the bootstrap root key in env** (never commit, never echo):
```bash
export ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID="<root key>"
export ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY="<root secret>"
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"
```
@@ -29,7 +29,7 @@ closes D-034 by having the user manually rotate the root key afterward.
```bash
python3 terraform/bootstrap/create_iam_user.py
```
Prints `ACDL_AWS_ACCESS_KEY_ID=<...>` + `ACDL_AWS_SECRET_ACCESS_KEY=<...>`
Prints `NOVA_AWS_ACCESS_KEY_ID=<...>` + `NOVA_AWS_SECRET_ACCESS_KEY=<...>`
to stdout (capture if you want the initial key; `rotate_spike_key.sh`
creates a fresh one anyway).
@@ -38,9 +38,9 @@ closes D-034 by having the user manually rotate the root key afterward.
```bash
bash scripts/rotate_spike_key.sh
```
Optionally uploads to Gitea Actions secrets if `ACDL_GITEA_TOKEN` is set.
Optionally uploads to Gitea Actions secrets if `NOVA_GITEA_TOKEN` is set.
5. **Verify** (manual): confirm the caller identity is `acdl-spike-runner`
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.
(`scripts/verify_phase08.sh` was the automated gate; it has been
@@ -49,33 +49,33 @@ closes D-034 by having the user manually rotate the root key afterward.
6. **MANUAL — D-034 closure:** 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 `acdl-spike-runner` key for Phases 09-10.
rotated `nova-spike-runner` key for Phases 09-10.
## v1.11 Phase 56 — IAM re-bootstrap + OIDC role (REQ-116)
The v1.11 milestone re-bootstraps IAM to close G-005 (CAP-017..022
deploy-unverified). Phase 56 extends the spike-runner policy with
CloudFront/WAF/Lambda/DynamoDB-contracts/SecretsManager/SNS/CE/KMS/OIDC
permissions and re-creates the `acdl-act-runner-role` (CAP-022).
permissions and re-creates the `nova-act-runner-role` (CAP-022).
**Apply the IAM baseline (idempotent):**
```bash
export ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID="<root key>"
export ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY="<root secret>"
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/apply_iam_baseline.py
```
This script:
1. Creates (or versions) the customer-managed policy
`acdl-spike-runner-policy` from
`nova-spike-runner-policy` from
`terraform/bootstrap/spike_runner_policy.json` (ARN
`arn:aws:iam::581513795199:policy/acdl-spike-runner-policy`).
2. Attaches it to the `acdl-spike-runner` user and deletes any leftover
`arn:aws:iam::581513795199:policy/nova-spike-runner-policy`).
2. Attaches it to the `nova-spike-runner` user and deletes any leftover
inline policy (the v1.1 inline policy hit the 2048-byte limit; the
managed-policy path supports 6144 bytes per version + up to 5
versions).
3. Re-creates the `acdl-act-runner-role` OIDC role if absent, attaches
3. Re-creates the `nova-act-runner-role` OIDC role if absent, attaches
the same managed policy, and sets a trust policy that permits root
assume until go-gitea/gitea#36988 merges real OIDC federation.
@@ -84,9 +84,9 @@ regression-tested by `tests/test_iam_policy_baseline.py` (15 tests).
## What the spike uses for Phases 09-10
- **State backend:** S3 bucket `acdl-tfstate-581513795199-us-east-1` +
DynamoDB table `acdl-outbox` (one table for both lock + outbox, D-P08-1).
- **Auth:** the rotated `acdl-spike-runner` key in `.env.secrets`
- **State backend:** S3 bucket `nova-tfstate-581513795199-us-east-1` +
DynamoDB table `nova-outbox` (one table for both lock + outbox, D-P08-1).
- **Auth:** the rotated `nova-spike-runner` key in `.env.secrets`
(gitignored, chmod 600). Re-rotate after each spike run via
`rotate_spike_key.sh` (D-039).
@@ -95,6 +95,6 @@ regression-tested by `tests/test_iam_policy_baseline.py` (15 tests).
| Concern | Spike (Phase 08) | v1.2 |
|---------|------------------|------|
| AWS auth | per-run-rotated long-lived key (D-039 waiver) | real OIDC federation (go-gitea/gitea#36988) |
| IAM | minimal user `acdl-spike-runner` + scoped policy | OIDC role + trust policy (no user, no key) |
| IAM | minimal user `nova-spike-runner` + scoped policy | OIDC role + trust policy (no user, no key) |
| State backend | S3 + DynamoDB single-region (us-east-1) | multi-region |
| Secret storage | gitignored `.env.secrets` + optional Gitea secret | Gitea OIDC-issued web-identity token (no secret) |
+7 -7
View File
@@ -1,4 +1,4 @@
"""Apply the ACDL spike-runner managed policy + OIDC act_runner role.
"""Apply the Nova spike-runner managed policy + OIDC act_runner role.
Phase 56 (REQ-116, v1.11). Idempotent: re-running creates the managed
policy if absent (or creates a new version if the policy document
@@ -31,10 +31,10 @@ import boto3
ROOT = Path(__file__).resolve().parent.parent.parent
POLICY_PATH = ROOT / "terraform" / "bootstrap" / "spike_runner_policy.json"
ACCOUNT = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
USER = "acdl-spike-runner"
POLICY_NAME = "acdl-spike-runner-policy"
USER = "nova-spike-runner"
POLICY_NAME = "nova-spike-runner-policy"
POLICY_ARN = f"arn:aws:iam::{ACCOUNT}:policy/{POLICY_NAME}"
ROLE_NAME = "acdl-act-runner-role"
ROLE_NAME = "nova-act-runner-role"
def _session():
@@ -86,7 +86,7 @@ def apply_managed_policy(iam, policy_doc: str) -> str:
PolicyName=POLICY_NAME,
Path="/",
PolicyDocument=policy_doc,
Description="ACDL spike-runner baseline (v1.11 REQ-116). Extended from inline user policy to managed policy to fit the 6144-byte limit.",
Description="Nova spike-runner baseline (v1.11 REQ-116). Extended from inline user policy to managed policy to fit the 6144-byte limit.",
)
print(f"created: {created['Policy']['Arn']}")
return created["Policy"]["Arn"]
@@ -117,10 +117,10 @@ def ensure_runner_role(iam):
iam.create_role(
RoleName=ROLE_NAME,
AssumeRolePolicyDocument=json.dumps(_trust_policy_for_runner()),
Description="ACDL act_runner OIDC role (CAP-022, v1.11 Phase 56 re-creation). Trust policy permits root assume until go-gitea/gitea#36988 merges real OIDC federation.",
Description="Nova act_runner OIDC role (CAP-022, v1.11 Phase 56 re-creation). Trust policy permits root assume until go-gitea/gitea#36988 merges real OIDC federation.",
MaxSessionDuration=3600,
Tags=[
{"Key": "Project", "Value": "acdl"},
{"Key": "Project", "Value": "nova"},
{"Key": "Capability", "Value": "CAP-022"},
{"Key": "Milestone", "Value": "v1.11"},
{"Key": "ManagedBy", "Value": "ciagent"},
+3 -3
View File
@@ -1,4 +1,4 @@
"""Create the ACDL v1.1 spike IAM user + scoped inline policy + initial key.
"""Create the Nova v1.1 spike IAM user + scoped inline policy + initial key.
Idempotent: skips user creation if the user exists; creates an initial
access key if none active exists. Prints the key to stdout for the
@@ -32,8 +32,8 @@ import boto3
REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
USER_NAME = "acdl-spike-runner"
POLICY_NAME = "acdl-spike-runner-policy"
USER_NAME = "nova-spike-runner"
POLICY_NAME = "nova-spike-runner-policy"
POLICY_FILE = os.path.join(os.path.dirname(__file__), "spike_runner_policy.json")
+5 -5
View File
@@ -1,7 +1,7 @@
"""Create the ACDL v1.1 spike AWS state backend (idempotent).
"""Create the Nova v1.1 spike AWS state backend (idempotent).
- S3 bucket acdl-tfstate-<account_id>-us-east-1 (versioning enabled).
- DynamoDB table acdl-outbox (PAY_PER_REQUEST; PK contractId, SK
- S3 bucket nova-tfstate-<account_id>-us-east-1 (versioning enabled).
- DynamoDB table nova-outbox (PAY_PER_REQUEST; PK contractId, SK
eventType#eventTs) — used for BOTH Terraform state locking AND the
evidence outbox (D-P08-1).
@@ -31,8 +31,8 @@ import boto3
REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
ACCOUNT_ID = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
STATE_BUCKET = f"acdl-tfstate-{ACCOUNT_ID}-us-east-1"
OUTBOX_TABLE = "acdl-outbox"
STATE_BUCKET = f"nova-tfstate-{ACCOUNT_ID}-us-east-1"
OUTBOX_TABLE = "nova-outbox"
def main():
+12 -12
View File
@@ -12,8 +12,8 @@
"s3:GetBucketVersioning"
],
"Resource": [
"arn:aws:s3:::acdl-tfstate-581513795199-us-east-1",
"arn:aws:s3:::acdl-tfstate-581513795199-us-east-1/*"
"arn:aws:s3:::nova-tfstate-581513795199-us-east-1",
"arn:aws:s3:::nova-tfstate-581513795199-us-east-1/*"
]
},
{
@@ -27,7 +27,7 @@
"dynamodb:Scan",
"dynamodb:DescribeTable"
],
"Resource": "arn:aws:dynamodb:us-east-1:581513795199:table/acdl-outbox"
"Resource": "arn:aws:dynamodb:us-east-1:581513795199:table/nova-outbox"
},
{
"Effect": "Allow",
@@ -142,7 +142,7 @@
"lambda:UntagResource",
"lambda:PublishLayerVersion"
],
"Resource": "arn:aws:lambda:us-east-1:581513795199:function:acdl-*"
"Resource": "arn:aws:lambda:us-east-1:581513795199:function:nova-*"
},
{
"Effect": "Allow",
@@ -158,10 +158,10 @@
"dynamodb:Batch*"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:581513795199:table/acdl-contracts",
"arn:aws:dynamodb:us-east-1:581513795199:table/acdl-contracts/*",
"arn:aws:dynamodb:us-east-1:581513795199:table/acdl-change-requests",
"arn:aws:dynamodb:us-east-1:581513795199:table/acdl-change-requests/*"
"arn:aws:dynamodb:us-east-1:581513795199:table/nova-contracts",
"arn:aws:dynamodb:us-east-1:581513795199:table/nova-contracts/*",
"arn:aws:dynamodb:us-east-1:581513795199:table/nova-change-requests",
"arn:aws:dynamodb:us-east-1:581513795199:table/nova-change-requests/*"
]
},
{
@@ -174,7 +174,7 @@
"secretsmanager:DeleteSecret",
"secretsmanager:ListSecrets"
],
"Resource": "arn:aws:secretsmanager:us-east-1:581513795199:secret:acdl/*"
"Resource": "arn:aws:secretsmanager:us-east-1:581513795199:secret:nova/*"
},
{
"Effect": "Allow",
@@ -186,7 +186,7 @@
"sns:DeleteTopic",
"sns:ListTopics"
],
"Resource": "arn:aws:sns:us-east-1:581513795199:acdl-*"
"Resource": "arn:aws:sns:us-east-1:581513795199:nova-*"
},
{
"Effect": "Allow",
@@ -217,7 +217,7 @@
],
"Resource": [
"arn:aws:kms:*:*:key/*",
"arn:aws:kms:*:*:alias/acdl-*"
"arn:aws:kms:*:*:alias/nova-*"
]
},
{
@@ -236,7 +236,7 @@
"iam:TagRole",
"iam:UntagRole"
],
"Resource": "arn:aws:iam::*:role/acdl-*"
"Resource": "arn:aws:iam::*:role/nova-*"
}
]
}
+7 -7
View File
@@ -1,4 +1,4 @@
# ACDL CI VPC — short-lived VPC for L1 module lifecycle testing.
# Nova CI VPC — short-lived VPC for L1 module lifecycle testing.
#
# Created by the modules-lifecycle pipeline before testing VPC-dependent
# modules (alb, ecs-service, rds, uptime). Destroyed after all tests complete.
@@ -15,7 +15,7 @@ terraform {
}
}
backend "s3" {
bucket = "acdl-tfstate-581513795199-us-east-1"
bucket = "nova-tfstate-581513795199-us-east-1"
key = "spike/ci-vpc/terraform.tfstate"
region = "us-east-1"
}
@@ -32,7 +32,7 @@ data "aws_availability_zones" "available" {
resource "aws_vpc" "ci" {
cidr_block = "10.1.0.0/16"
tags = {
Name = "acdl-ci-vpc"
Name = "nova-ci-vpc"
"nova:owner" = "acdl"
"nova:environment" = "ci"
}
@@ -44,7 +44,7 @@ resource "aws_subnet" "ci" {
cidr_block = cidrsubnet(aws_vpc.ci.cidr_block, 8, count.index + 1)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "acdl-ci-subnet-${count.index}"
Name = "nova-ci-subnet-${count.index}"
"nova:owner" = "acdl"
"nova:environment" = "ci"
}
@@ -53,7 +53,7 @@ resource "aws_subnet" "ci" {
resource "aws_internet_gateway" "ci" {
vpc_id = aws_vpc.ci.id
tags = {
Name = "acdl-ci-igw"
Name = "nova-ci-igw"
}
}
@@ -72,7 +72,7 @@ resource "aws_route_table_association" "ci" {
}
resource "aws_security_group" "ecs" {
name = "acdl-ci-ecs-sg"
name = "nova-ci-ecs-sg"
description = "Security group for CI ECS services"
vpc_id = aws_vpc.ci.id
@@ -92,7 +92,7 @@ resource "aws_security_group" "ecs" {
}
resource "aws_ecs_cluster" "ci" {
name = "acdl-ci-cluster"
name = "nova-ci-cluster"
}
output "vpc_id" {
+29 -29
View File
@@ -1,7 +1,7 @@
resource "aws_vpc" "vpc-vpc" {
cidr_block = "10.0.0.0/16"
tags = {
Name = "acdl-microservice"
Name = "nova-microservice"
}
}
@@ -11,9 +11,9 @@ output "vpc_id" {
resource "aws_subnet" "vpc-subnet" {
cidr_block = "10.0.0.0/16"
vpc_id = aws_vpc.vpc-vpc.id
vpc_id = aws_vpc.vpc-vpc.id
tags = {
Name = "acdl-microservice"
Name = "nova-microservice"
}
}
@@ -24,12 +24,12 @@ resource "aws_route_table" "vpc-routetable" {
gateway_id = aws_internet_gateway.vpc-igw.id
}
tags = {
Name = "acdl-microservice-rt"
Name = "nova-microservice-rt"
}
}
resource "aws_ecs_cluster" "cluster" {
name = "acdl-microservice"
name = "nova-microservice"
}
output "cluster_arn" {
@@ -41,7 +41,7 @@ output "cluster_id" {
}
resource "aws_ecr_repository" "ecr" {
name = "acdl-microservice"
name = "nova-microservice"
}
output "repository_url" {
@@ -53,8 +53,8 @@ output "repository_arn" {
}
resource "aws_iam_role" "roles" {
name = "acdl-microservice-exec"
assume_role_policy = jsonencode({"Statement": [{"Action": "sts:AssumeRole", "Effect": "Allow", "Principal": {"Service": "ecs-tasks.amazonaws.com"}}], "Version": "2012-10-17"})
name = "nova-microservice-exec"
assume_role_policy = jsonencode({ "Statement" : [{ "Action" : "sts:AssumeRole", "Effect" : "Allow", "Principal" : { "Service" : "ecs-tasks.amazonaws.com" } }], "Version" : "2012-10-17" })
managed_policy_arns = ["arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"]
}
@@ -67,9 +67,9 @@ output "role_id" {
}
resource "aws_lb" "alb-loadbalancer" {
name = "acdl-microservice"
subnets = [aws_subnet.vpc-subnet.id]
security_groups = [aws_iam_role.roles.arn]
name = "nova-microservice"
subnets = [aws_subnet.vpc-subnet.id]
security_groups = [aws_iam_role.roles.arn]
load_balancer_type = "application"
}
@@ -78,11 +78,11 @@ output "lb_arn" {
}
resource "aws_lb_target_group" "alb-targetgroup" {
name = "acdl-microservice"
port = 8080
name = "nova-microservice"
port = 8080
target_type = "ip"
vpc_id = aws_vpc.vpc-vpc.id
protocol = "HTTP"
vpc_id = aws_vpc.vpc-vpc.id
protocol = "HTTP"
}
output "target_group_arn" {
@@ -92,7 +92,7 @@ output "target_group_arn" {
resource "aws_lb_listener" "alb-listener" {
port = 8080
default_action {
type = "forward"
type = "forward"
target_group_arn = aws_lb_target_group.alb-targetgroup.arn
}
load_balancer_arn = aws_lb.alb-loadbalancer.id
@@ -103,10 +103,10 @@ output "listener_arn" {
}
resource "aws_ecs_task_definition" "service-taskdefinition" {
cpu = 256
memory = 512
container_definitions = jsonencode([{"essential": true, "image": "581513795199.dkr.ecr.us-east-1.amazonaws.com/acdl-microservice:latest", "name": "app", "portMappings": [{"containerPort": 8080}]}])
family = "app"
cpu = 256
memory = 512
container_definitions = jsonencode([{ "essential" : true, "image" : "581513795199.dkr.ecr.us-east-1.amazonaws.com/nova-microservice:latest", "name" : "app", "portMappings" : [{ "containerPort" : 8080 }] }])
family = "app"
}
output "task_def_arn" {
@@ -117,17 +117,17 @@ resource "aws_ecs_service" "service-service" {
cluster = aws_ecs_cluster.cluster.arn
load_balancer {
target_group_arn = aws_lb_target_group.alb-targetgroup.arn
container_name = "app"
container_port = 8080
container_name = "app"
container_port = 8080
}
network_configuration {
subnets = [aws_subnet.vpc-subnet.id]
subnets = [aws_subnet.vpc-subnet.id]
security_groups = [aws_iam_role.roles.arn]
}
desired_count = 1
launch_type = "FARGATE"
desired_count = 1
launch_type = "FARGATE"
task_definition = aws_ecs_task_definition.service-taskdefinition.arn
name = "acdl-microservice"
name = "nova-microservice"
}
output "service_arn" {
@@ -137,11 +137,11 @@ output "service_arn" {
resource "aws_internet_gateway" "vpc-igw" {
vpc_id = aws_vpc.vpc-vpc.id
tags = {
Name = "acdl-microservice-igw"
Name = "nova-microservice-igw"
}
}
resource "aws_route_table_association" "vpc-rta" {
subnet_id = aws_subnet.vpc-subnet.id
subnet_id = aws_subnet.vpc-subnet.id
route_table_id = aws_route_table.vpc-routetable.id
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ terraform {
}
}
backend "s3" {
bucket = "acdl-tfstate-581513795199-us-east-1"
bucket = "nova-tfstate-581513795199-us-east-1"
key = "spike/microservice/terraform.tfstate"
region = "us-east-1"
}
+8 -8
View File
@@ -1,4 +1,4 @@
# ACDL Platform Infrastructure (D-051)
# Nova Platform Infrastructure (D-051)
Terraform configuration for the **platform-side** infrastructure that
ingests consumer deployment contracts and (Phase 25) reports errors as
@@ -13,11 +13,11 @@ consumers — the contract ingestion pipeline and the secrets it needs.
| Resource | Name | Purpose |
|----------|------|---------|
| `aws_dynamodb_table` | `acdl-contracts` | Stores submitted consumer contracts. PK `consumerRepo`, SK `contractId#submittedAt`. SSE via CMK, PITR enabled. |
| `aws_kms_key` + `aws_kms_alias` | `alias/acdl-platform` | Customer-managed key — encrypts DynamoDB SSE, Secrets Manager, and SSM. Key rotation enabled. |
| `aws_secretsmanager_secret` | `acdl/github-token` | GitHub PAT used by the Lambda to create issues on the platform repo (D-055, wired in Phase 25). |
| `aws_iam_role` + `aws_iam_role_policy` | `acdl-contract-ingestor-role` | Execution role for the Lambda — DynamoDB write, Secrets Manager read, KMS decrypt, CloudWatch logs. |
| `aws_lambda_function` | `acdl-contract-ingestor` | Python 3.12 Lambda. Handler `contract_ingestor.lambda_handler`. Source: `core/lambda/contract_ingestor.py`, packaged as `contract_ingestor.zip`. |
| `aws_dynamodb_table` | `nova-contracts` | Stores submitted consumer contracts. PK `consumerRepo`, SK `contractId#submittedAt`. SSE via CMK, PITR enabled. |
| `aws_kms_key` + `aws_kms_alias` | `alias/nova-platform` | Customer-managed key — encrypts DynamoDB SSE, Secrets Manager, and SSM. Key rotation enabled. |
| `aws_secretsmanager_secret` | `nova/github-token` | GitHub PAT used by the Lambda to create issues on the platform repo (D-055, wired in Phase 25). |
| `aws_iam_role` + `aws_iam_role_policy` | `nova-contract-ingestor-role` | Execution role for the Lambda — DynamoDB write, Secrets Manager read, KMS decrypt, CloudWatch logs. |
| `aws_lambda_function` | `nova-contract-ingestor` | Python 3.12 Lambda. Handler `contract_ingestor.lambda_handler`. Source: `core/lambda/contract_ingestor.py`, packaged as `contract_ingestor.zip`. |
| `aws_lambda_function_url` | — | Function URL with `AWS_IAM` authorization. Consumers invoke it via SigV4-signed requests. |
## State
@@ -25,7 +25,7 @@ consumers — the contract ingestion pipeline and the secrets it needs.
| Key | Value |
|-----|-------|
| Backend | S3 |
| Bucket | `acdl-tfstate-581513795199-us-east-1` |
| Bucket | `nova-tfstate-581513795199-us-east-1` |
| State key | `platform/terraform.tfstate` |
| Region | `us-east-1` |
@@ -66,7 +66,7 @@ flow:
request with SigV4 using its deploy-role credentials. The IAM auth on
the Function URL validates the signature and the ABAC condition.
3. **Lambda.** The Lambda parses the JSON body, validates the fields,
and writes the contract to `acdl-contracts`.
and writes the contract to `nova-contracts`.
This is a **one-way** channel (D-051): the consumer pushes contracts
*to* the platform; the platform never reaches back into the consumer
@@ -4,7 +4,7 @@
{
"Effect": "Allow",
"Action": "lambda:InvokeFunctionUrl",
"Resource": "arn:aws:lambda:${region}:${account_id}:function:acdl-contract-ingestor",
"Resource": "arn:aws:lambda:${region}:${account_id}:function:nova-contract-ingestor",
"Condition": {
"StringEquals": {
"aws:PrincipalTag/nova:owner": "${consumerRepo}"
+54 -54
View File
@@ -1,11 +1,11 @@
# ACDL platform infrastructure — contract ingestion Lambda + DynamoDB (D-051)
# Nova platform infrastructure — contract ingestion Lambda + DynamoDB (D-051)
#
# Deploys:
# - DynamoDB table acdl-contracts (PK consumerRepo, SK contractId#submittedAt, SSE via CMK, PITR)
# - DynamoDB table nova-contracts (PK consumerRepo, SK contractId#submittedAt, SSE via CMK, PITR)
# - KMS customer-managed key for DynamoDB + SSM (shared CMK)
# - Lambda function acdl-contract-ingestor (Python 3.12, handler contract_ingestor.lambda_handler)
# - Lambda function nova-contract-ingestor (Python 3.12, handler contract_ingestor.lambda_handler)
# - Lambda Function URL (IAM auth — consumers invoke via SigV4)
# - Secrets Manager secret acdl/github-token (stores the Lambda's GitHub PAT for issue creation)
# - Secrets Manager secret nova/github-token (stores the Lambda's GitHub PAT for issue creation)
# - IAM execution role for the Lambda (DynamoDB write + Secrets Manager read + KMS decrypt)
#
# State: terraform/platform/terraform.tfstate (separate from spike/ and microservice/)
@@ -19,7 +19,7 @@ terraform {
}
}
backend "s3" {
bucket = "acdl-tfstate-581513795199-us-east-1"
bucket = "nova-tfstate-581513795199-us-east-1"
key = "platform/terraform.tfstate"
region = "us-east-1"
}
@@ -37,20 +37,20 @@ variable "vpc_cidr" {
}
# KMS customer-managed key for DynamoDB SSE + SSM Parameter Store encryption
resource "aws_kms_key" "acdl_platform" {
description = "ACDL platform KMS key (DynamoDB SSE + SSM + Secrets Manager)"
resource "aws_kms_key" "nova_platform" {
description = "Nova platform KMS key (DynamoDB SSE + SSM + Secrets Manager)"
enable_key_rotation = true
deletion_window_in_days = 30
}
resource "aws_kms_alias" "acdl_platform" {
name = "alias/acdl-platform"
target_key_id = aws_kms_key.acdl_platform.key_id
resource "aws_kms_alias" "nova_platform" {
name = "alias/nova-platform"
target_key_id = aws_kms_key.nova_platform.key_id
}
# DynamoDB table for contract ingestion
resource "aws_dynamodb_table" "acdl_contracts" {
name = "acdl-contracts"
resource "aws_dynamodb_table" "nova_contracts" {
name = "nova-contracts"
billing_mode = "PAY_PER_REQUEST"
hash_key = "consumerRepo"
range_key = "contractId#submittedAt"
@@ -71,7 +71,7 @@ resource "aws_dynamodb_table" "acdl_contracts" {
server_side_encryption {
enabled = true
kms_key_arn = aws_kms_key.acdl_platform.arn
kms_key_arn = aws_kms_key.nova_platform.arn
}
tags = {
@@ -84,9 +84,9 @@ resource "aws_dynamodb_table" "acdl_contracts" {
# Secrets Manager secret for the Lambda's GitHub token (issue creation)
resource "aws_secretsmanager_secret" "github_token" {
name = "acdl/github-token"
name = "nova/github-token"
description = "GitHub PAT for the platform Lambda to create issues on the platform repo (D-055)."
kms_key_id = aws_kms_key.acdl_platform.arn
kms_key_id = aws_kms_key.nova_platform.arn
tags = {
"nova:owner" = "acdl"
@@ -98,7 +98,7 @@ resource "aws_secretsmanager_secret" "github_token" {
# IAM execution role for the Lambda
resource "aws_iam_role" "lambda_exec" {
name = "acdl-contract-ingestor-role"
name = "nova-contract-ingestor-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
@@ -110,7 +110,7 @@ resource "aws_iam_role" "lambda_exec" {
}
resource "aws_iam_role_policy" "lambda_permissions" {
name = "acdl-contract-ingestor-policy"
name = "nova-contract-ingestor-policy"
role = aws_iam_role.lambda_exec.id
policy = jsonencode({
Version = "2012-10-17"
@@ -118,12 +118,12 @@ resource "aws_iam_role_policy" "lambda_permissions" {
{
Effect = "Allow"
Action = ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:Query", "dynamodb:UpdateItem"]
Resource = aws_dynamodb_table.acdl_contracts.arn
Resource = aws_dynamodb_table.nova_contracts.arn
},
{
Effect = "Allow"
Action = ["dynamodb:GetItem", "dynamodb:Query"]
Resource = aws_dynamodb_table.acdl_change_requests.arn
Resource = aws_dynamodb_table.nova_change_requests.arn
},
{
Effect = "Allow"
@@ -133,7 +133,7 @@ resource "aws_iam_role_policy" "lambda_permissions" {
{
Effect = "Allow"
Action = ["kms:Decrypt"]
Resource = aws_kms_key.acdl_platform.arn
Resource = aws_kms_key.nova_platform.arn
},
{
Effect = "Allow"
@@ -152,7 +152,7 @@ locals {
resource "aws_lambda_function" "contract_ingestor" {
count = local.lambda_zip_exists ? 1 : 0
function_name = "acdl-contract-ingestor"
function_name = "nova-contract-ingestor"
handler = "contract_ingestor.lambda_handler"
runtime = "python3.12"
role = aws_iam_role.lambda_exec.arn
@@ -161,9 +161,9 @@ resource "aws_lambda_function" "contract_ingestor" {
environment {
variables = {
CONTRACTS_TABLE = aws_dynamodb_table.acdl_contracts.name
CONTRACTS_TABLE = aws_dynamodb_table.nova_contracts.name
GITHUB_TOKEN_SECRET_ID = aws_secretsmanager_secret.github_token.name
PLATFORM_REPO = "acdl/acdl"
PLATFORM_REPO = "nova/acdl"
}
}
@@ -204,8 +204,8 @@ output "consumer_invoke_policy_rendered" {
}
# REQ-93: DynamoDB table for change requests (CMDB for decommission validation)
resource "aws_dynamodb_table" "acdl_change_requests" {
name = "acdl-change-requests"
resource "aws_dynamodb_table" "nova_change_requests" {
name = "nova-change-requests"
billing_mode = "PAY_PER_REQUEST"
hash_key = "changeRequestId"
range_key = "submittedAt"
@@ -226,7 +226,7 @@ resource "aws_dynamodb_table" "acdl_change_requests" {
server_side_encryption {
enabled = true
kms_key_arn = aws_kms_key.acdl_platform.arn
kms_key_arn = aws_kms_key.nova_platform.arn
}
tags = {
@@ -237,10 +237,10 @@ resource "aws_dynamodb_table" "acdl_change_requests" {
}
}
# REQ-107: SNS topic for separation-of-duties halt artifacts.
# route_halt_artifact publishes here when ACDL_SOD_HALT_TOPIC_ARN is set.
resource "aws_sns_topic" "acdl_sod_halt" {
name = "acdl-sod-halt"
kms_master_key_id = aws_kms_key.acdl_platform.id
# route_halt_artifact publishes here when NOVA_SOD_HALT_TOPIC_ARN is set.
resource "aws_sns_topic" "nova_sod_halt" {
name = "nova-sod-halt"
kms_master_key_id = aws_kms_key.nova_platform.id
tags = {
"nova:owner" = "acdl"
"nova:contract" = "platform"
@@ -249,8 +249,8 @@ resource "aws_sns_topic" "acdl_sod_halt" {
}
}
output "acdl_sod_halt_topic_arn" {
value = aws_sns_topic.acdl_sod_halt.arn
output "nova_sod_halt_topic_arn" {
value = aws_sns_topic.nova_sod_halt.arn
}
# ---------------------------------------------------------------------------
@@ -258,10 +258,10 @@ output "acdl_sod_halt_topic_arn" {
# via terraform_remote_state (data source). No per-contract VPC ever again.
# ---------------------------------------------------------------------------
resource "aws_vpc" "acdl_shared" {
resource "aws_vpc" "nova_shared" {
cidr_block = var.vpc_cidr
tags = {
Name = "acdl-shared"
Name = "nova-shared"
"nova:owner" = "acdl"
"nova:contract" = "platform"
"nova:environment" = "shared"
@@ -269,13 +269,13 @@ resource "aws_vpc" "acdl_shared" {
}
}
resource "aws_subnet" "acdl_shared" {
resource "aws_subnet" "nova_shared" {
count = length(data.aws_availability_zones.available.names)
vpc_id = aws_vpc.acdl_shared.id
cidr_block = cidrsubnet(aws_vpc.acdl_shared.cidr_block, 8, count.index + 1)
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 = "acdl-shared-subnet-${count.index}"
Name = "nova-shared-subnet-${count.index}"
"nova:owner" = "acdl"
"nova:contract" = "platform"
"nova:environment" = "shared"
@@ -287,10 +287,10 @@ data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_internet_gateway" "acdl_shared" {
vpc_id = aws_vpc.acdl_shared.id
resource "aws_internet_gateway" "nova_shared" {
vpc_id = aws_vpc.nova_shared.id
tags = {
Name = "acdl-shared-igw"
Name = "nova-shared-igw"
"nova:owner" = "acdl"
"nova:contract" = "platform"
"nova:environment" = "shared"
@@ -298,14 +298,14 @@ resource "aws_internet_gateway" "acdl_shared" {
}
}
resource "aws_route_table" "acdl_shared" {
vpc_id = aws_vpc.acdl_shared.id
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.acdl_shared.id
gateway_id = aws_internet_gateway.nova_shared.id
}
tags = {
Name = "acdl-shared-rt"
Name = "nova-shared-rt"
"nova:owner" = "acdl"
"nova:contract" = "platform"
"nova:environment" = "shared"
@@ -313,16 +313,16 @@ resource "aws_route_table" "acdl_shared" {
}
}
resource "aws_route_table_association" "acdl_shared" {
resource "aws_route_table_association" "nova_shared" {
count = 2
subnet_id = aws_subnet.acdl_shared[count.index].id
route_table_id = aws_route_table.acdl_shared.id
subnet_id = aws_subnet.nova_shared[count.index].id
route_table_id = aws_route_table.nova_shared.id
}
resource "aws_security_group" "ecs" {
name = "acdl-ecs-sg"
name = "nova-ecs-sg"
description = "Security group for ECS Fargate services (platform VPC)"
vpc_id = aws_vpc.acdl_shared.id
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
@@ -343,7 +343,7 @@ resource "aws_security_group" "ecs" {
}
tags = {
Name = "acdl-ecs-sg"
Name = "nova-ecs-sg"
"nova:owner" = "acdl"
"nova:contract" = "platform"
"nova:environment" = "shared"
@@ -352,16 +352,16 @@ resource "aws_security_group" "ecs" {
}
output "vpc_id" {
value = aws_vpc.acdl_shared.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.acdl_shared[*].id)
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."
}
}
+7 -7
View File
@@ -66,7 +66,7 @@ def moto_contracts_table(monkeypatch):
with mock_aws():
dyn = boto3.client("dynamodb", region_name="us-east-1")
dyn.create_table(
TableName="acdl-contracts",
TableName="nova-contracts",
KeySchema=[
{"AttributeName": "consumerRepo", "KeyType": "HASH"},
{"AttributeName": "contractId#submittedAt", "KeyType": "RANGE"},
@@ -84,7 +84,7 @@ def moto_contracts_table(monkeypatch):
saved_secrets = ingestor._secrets_client
ingestor._dynamodb = None
ingestor._secrets_client = None
monkeypatch.setattr(ingestor, "TABLE_NAME", "acdl-contracts")
monkeypatch.setattr(ingestor, "TABLE_NAME", "nova-contracts")
yield dyn
@@ -108,7 +108,7 @@ class TestSubmitContract:
# Verify what landed in DynamoDB.
sk = f"contract-001#{result['submittedAt']}"
resp = moto_contracts_table.get_item(
TableName="acdl-contracts",
TableName="nova-contracts",
Key={
"consumerRepo": {"S": "acdl/consumer-a"},
"contractId#submittedAt": {"S": sk},
@@ -369,7 +369,7 @@ class TestCallerIdentityValidation:
def test_invalid_consumer_repo_format_rejected(self, moto_contracts_table, sample_payload):
# A consumerRepo without "/" is invalid (not org/repo format).
sample_payload["consumerRepo"] = "not-a-repo-format"
event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": "arn:aws:sts::000:assumed-role/acdl-deploy/session"}}}
event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": "arn:aws:sts::000:assumed-role/nova-deploy/session"}}}
resp = ingestor.lambda_handler(event, None)
assert resp["statusCode"] == 400
assert "invalid consumerRepo" in json.loads(resp["body"])["error"]
@@ -378,7 +378,7 @@ class TestCallerIdentityValidation:
# A valid org/repo consumerRepo with an identity present — passes.
event = {
"body": json.dumps(sample_payload),
"requestContext": {"identity": {"userArn": "arn:aws:sts::000:assumed-role/acdl-deploy/acdl-consumer-a"}},
"requestContext": {"identity": {"userArn": "arn:aws:sts::000:assumed-role/nova-deploy/nova-consumer-a"}},
}
resp = ingestor.lambda_handler(event, None)
assert resp["statusCode"] == 200
@@ -431,12 +431,12 @@ class TestValidateChangeRequest:
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
monkeypatch.setenv("CHANGE_REQUESTS_TABLE", "acdl-change-requests")
monkeypatch.setenv("CHANGE_REQUESTS_TABLE", "nova-change-requests")
with mock_aws():
dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
table = dynamodb.create_table(
TableName="acdl-change-requests",
TableName="nova-change-requests",
KeySchema=[
{"AttributeName": "changeRequestId", "KeyType": "HASH"},
{"AttributeName": "submittedAt", "KeyType": "RANGE"},
+1 -1
View File
@@ -67,7 +67,7 @@ class TestResolveMicroservice:
"microservice": {
"version": "1.0.0",
"inputs": {
"image": "581513795199.dkr.ecr.us-east-1.amazonaws.com/acdl-microservice:latest",
"image": "581513795199.dkr.ecr.us-east-1.amazonaws.com/nova-microservice:latest",
"port": 8080,
"region": "us-east-1",
},
+12 -12
View File
@@ -134,13 +134,13 @@ class TestIAMPolicyBaseline:
def test_dynamodb_contracts_table_in_resource(self, policy):
contracts_stmts = [
s for s in policy["Statement"]
if any("acdl-contracts" in r for r in (
if any("nova-contracts" in r for r in (
s.get("Resource") if isinstance(s.get("Resource"), list) else [s.get("Resource", "")]
))
]
assert contracts_stmts, "no statement references the acdl-contracts table"
assert contracts_stmts, "no statement references the nova-contracts table"
def test_lambda_scoped_to_acdl_functions(self, policy):
def test_lambda_scoped_to_nova_functions(self, policy):
lambda_stmts = [s for s in policy["Statement"] if any(
a.startswith("lambda:") for a in (
s.get("Action") if isinstance(s.get("Action"), list) else [s.get("Action", "")]
@@ -151,8 +151,8 @@ class TestIAMPolicyBaseline:
res = s.get("Resource", "")
if isinstance(res, list):
res = " ".join(res)
assert "function:acdl-*" in res or res == "*", \
"lambda actions not scoped to acdl-* functions"
assert "function:nova-*" in res or res == "*", \
"lambda actions not scoped to nova-* functions"
def test_cost_explorer_is_read_only(self, policy):
ce_actions = set()
@@ -178,8 +178,8 @@ class TestIAMPolicyBaseline:
res = " ".join(res)
assert res != "*", "iam:PassRole must not be granted to Resource: *"
def test_iam_role_creation_scoped_to_acdl_prefix(self, policy):
"""G-104: iam:CreateRole must be scoped to role/acdl-* (not Resource: *)."""
def test_iam_role_creation_scoped_to_nova_prefix(self, policy):
"""G-104: iam:CreateRole must be scoped to role/nova-* (not Resource: *)."""
for s in policy["Statement"]:
acts = s.get("Action", [])
if isinstance(acts, str):
@@ -188,10 +188,10 @@ class TestIAMPolicyBaseline:
res = s.get("Resource", "")
if isinstance(res, list):
res = " ".join(res)
assert "acdl-*" in res, f"iam:CreateRole must be scoped to acdl-* (got: {res})"
assert "nova-*" in res, f"iam:CreateRole must be scoped to nova-* (got: {res})"
def test_kms_scoped_to_acdl_alias(self, policy):
"""G-104: kms:CreateKey etc. must be scoped to alias/acdl-* (not Resource: *)."""
def test_kms_scoped_to_nova_alias(self, policy):
"""G-104: kms:CreateKey etc. must be scoped to alias/nova-* (not Resource: *)."""
for s in policy["Statement"]:
acts = s.get("Action", [])
if isinstance(acts, str):
@@ -200,7 +200,7 @@ class TestIAMPolicyBaseline:
res = s.get("Resource", "")
if isinstance(res, list):
res = " ".join(res)
assert "acdl-*" in res, f"kms actions must be scoped to acdl-* (got: {res})"
assert "nova-*" in res, f"kms actions must be scoped to nova-* (got: {res})"
def test_cloudfront_waf_remain_global(self, policy):
"""G-104: CloudFront + WAFv2 (CloudFront scope) ARNs are global;
@@ -215,4 +215,4 @@ class TestIAMPolicyBaseline:
if isinstance(res, list):
res = res[0] if res else ""
# CloudFront/WAFv2 are allowed to be * (global ARNs)
assert res == "*" or "acdl" in res
assert res == "*" or "nova" in res
+1 -1
View File
@@ -121,7 +121,7 @@ def test_local_s3_backend_rewrites_s3_to_local(tmp_path):
tf = tmp_path / "terraform.tf"
tf.write_text(
'terraform {\n required_version = ">= 1.9"\n backend "s3" {\n'
' bucket = "acdl-tfstate-x"\n key = "spike/s.tfstate"\n'
' bucket = "nova-tfstate-x"\n key = "spike/s.tfstate"\n'
' region = "us-east-1"\n }\n}\n'
)
backend.rewrite_terraform_tf(tf, "test-stack")
+148
View File
@@ -0,0 +1,148 @@
"""Unit tests for scripts/migrate_dynamodb_data.py (REQ-163, P4).
Tests the pure item-mapping logic + table-pair resolution. The AWS I/O
(scan_all/copy_items) is thin boto3 glue, not unit-tested here (covered
by the dry-run path + the runbook's live verification).
"""
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))
import migrate_dynamodb_data as mig
class TestMapItem:
def test_map_item_preserves_typed_attributes(self):
item = {
"consumerRepo": {"S": "acdl/consumer-a"},
"contractId#submittedAt": {"S": "c-1#2026-01-01T00:00:00Z"},
"contract": {"S": "name: foo\n"},
"count": {"N": "42"},
}
result = mig.map_item(item)
assert result == item
def test_map_item_returns_independent_copy(self):
"""The mapped item must not alias the scanned item (callers may mutate)."""
item = {"k": {"S": "v"}}
result = mig.map_item(item)
result["k"]["S"] = "mutated"
assert item["k"]["S"] == "v", "map_item returned an alias, not a copy"
def test_map_item_empty(self):
assert mig.map_item({}) == {}
def test_map_item_preserves_binary_and_nested(self):
item = {
"pk": {"B": b"\x01\x02"},
"nested": {"M": {"a": {"S": "x"}}},
"list": {"L": [{"S": "1"}, {"S": "2"}]},
}
assert mig.map_item(item) == item
class TestTablePair:
def test_contracts_alias(self):
assert mig.table_pair_for("contracts") == ("acdl-contracts", "nova-contracts")
def test_change_requests_alias(self):
assert mig.table_pair_for("change-requests") == (
"acdl-change-requests", "nova-change-requests"
)
def test_literal_source_name(self):
assert mig.table_pair_for("acdl-contracts") == ("acdl-contracts", "nova-contracts")
def test_literal_dest_name(self):
assert mig.table_pair_for("nova-contracts") == ("acdl-contracts", "nova-contracts")
def test_unknown_name_raises(self):
with pytest.raises(ValueError, match="unknown table"):
mig.table_pair_for("nope")
def test_custom_pairs(self):
pairs = [("old-x", "new-x")]
assert mig.table_pair_for("old-x", pairs=pairs) == ("old-x", "new-x")
class TestDefaultPairs:
def test_default_pairs_cover_both_tables(self):
sources = [s for s, _ in mig.DEFAULT_TABLE_PAIRS]
dests = [d for _, d in mig.DEFAULT_TABLE_PAIRS]
assert sources == ["acdl-contracts", "acdl-change-requests"]
assert dests == ["nova-contracts", "nova-change-requests"]
class TestArgparser:
def test_dry_run_default(self):
args = mig.build_parser().parse_args([])
assert args.apply is False
assert args.region == "us-east-1"
assert args.table is None
def test_apply_flag(self):
args = mig.build_parser().parse_args(["--apply"])
assert args.apply is True
def test_table_filter(self):
args = mig.build_parser().parse_args(["--table", "contracts"])
assert args.table == "contracts"
def test_source_dest_override(self):
args = mig.build_parser().parse_args(["--source", "old", "--dest", "new"])
assert args.source == "old"
assert args.dest == "new"
class TestRunDryRun:
"""The dry-run path exercises the table-pair resolution + describes both
tables without writing. We stub the boto3 client so no AWS access occurs."""
def _fake_client(self, describable=True):
client = type("FakeClient", (), {})()
def describe_table(TableName):
if not describable:
raise Exception("ResourceNotFoundException")
return {"Table": {"ItemCount": 0}}
client.describe_table = describe_table
client.scan = lambda **k: {"Items": []}
client.put_item = lambda **k: None
return client
def test_run_dry_run_reports_planned_copy(self, monkeypatch, capsys):
# Build args with both default pairs.
args = mig.build_parser().parse_args([])
# Stub the client constructor so no real boto3 client is built.
monkeypatch.setattr(mig.boto3, "client", lambda *a, **k: self._fake_client())
rc = mig.run(args)
out = capsys.readouterr().out
assert rc == 0
assert "DRY-RUN" in out
assert "acdl-contracts" in out and "nova-contracts" in out
assert "acdl-change-requests" in out and "nova-change-requests" in out
assert "would PutItem" in out
assert "NOT deleted" in out
def test_run_source_table_not_describable_fails(self, monkeypatch, capsys):
args = mig.build_parser().parse_args([])
# First describe_table (source) raises, second (dest) is fine — emulate by
# raising on the first call only.
calls = {"n": 0}
client = type("FakeClient", (), {})()
def describe_table(TableName):
calls["n"] += 1
if calls["n"] % 2 == 1: # source (odd calls)
raise Exception("ResourceNotFoundException")
return {"Table": {"ItemCount": 0}}
client.describe_table = describe_table
client.scan = lambda **k: {"Items": []}
client.put_item = lambda **k: None
monkeypatch.setattr(mig.boto3, "client", lambda *a, **k: client)
rc = mig.run(args)
err = capsys.readouterr().err
assert rc == 1
assert "not describable" in err
+7 -7
View File
@@ -55,7 +55,7 @@ class TestWriteEvent:
with mock_aws():
dyn = boto3.client("dynamodb", region_name="us-east-1")
dyn.create_table(
TableName="acdl-outbox",
TableName="nova-outbox",
KeySchema=[
{"AttributeName": "contractId", "KeyType": "HASH"},
{"AttributeName": "eventType#eventTs", "KeyType": "RANGE"},
@@ -68,7 +68,7 @@ class TestWriteEvent:
)
event = self._sample_event()
item = write_event(event, outbox_table="acdl-outbox", region="us-east-1")
item = write_event(event, outbox_table="nova-outbox", region="us-east-1")
assert item["contractId"]["S"] == "test-contract-001"
assert item["prev_event_hash"]["S"] == "GENESIS"
@@ -84,7 +84,7 @@ class TestWriteEvent:
with mock_aws():
dyn = boto3.client("dynamodb", region_name="us-east-1")
dyn.create_table(
TableName="acdl-outbox",
TableName="nova-outbox",
KeySchema=[
{"AttributeName": "contractId", "KeyType": "HASH"},
{"AttributeName": "eventType#eventTs", "KeyType": "RANGE"},
@@ -97,7 +97,7 @@ class TestWriteEvent:
)
event = self._sample_event()
item = write_event(event, outbox_table="acdl-outbox", region="us-east-1")
item = write_event(event, outbox_table="nova-outbox", region="us-east-1")
expected_hash = _canonical_hash(event)
assert item["hash"]["S"] == expected_hash
@@ -109,7 +109,7 @@ class TestWriteEvent:
with mock_aws():
dyn = boto3.client("dynamodb", region_name="us-east-1")
dyn.create_table(
TableName="acdl-outbox",
TableName="nova-outbox",
KeySchema=[
{"AttributeName": "contractId", "KeyType": "HASH"},
{"AttributeName": "eventType#eventTs", "KeyType": "RANGE"},
@@ -122,10 +122,10 @@ class TestWriteEvent:
)
event = self._sample_event()
write_event(event, outbox_table="acdl-outbox", region="us-east-1")
write_event(event, outbox_table="nova-outbox", region="us-east-1")
resp = dyn.get_item(
TableName="acdl-outbox",
TableName="nova-outbox",
Key={
"contractId": {"S": "test-contract-001"},
"eventType#eventTs": {"S": "CONFIDENCE_COMPUTED#2026-07-22T00:00:00Z"},
+1 -1
View File
@@ -455,7 +455,7 @@ class TestInvokePolicyTemplate:
rendered = template.replace("${account_id}", "123456789012").replace("${region}", "us-east-1")
policy = json.loads(rendered)
resource_arn = policy["Statement"][0]["Resource"]
assert resource_arn == "arn:aws:lambda:us-east-1:123456789012:function:acdl-contract-ingestor"
assert resource_arn == "arn:aws:lambda:us-east-1:123456789012:function:nova-contract-ingestor"
assert "000000000000" not in resource_arn
# ${consumerRepo} is a runtime placeholder (not a Terraform variable) — it stays.
assert "${account_id}" not in rendered
+6 -6
View File
@@ -15,13 +15,13 @@ from core.separation_of_duties import route_halt_artifact
def test_route_halt_publishes_to_sns_when_arn_set(monkeypatch):
"""With ACDL_SOD_HALT_TOPIC_ARN set, the SNS client receives the publish."""
monkeypatch.setenv("ACDL_SOD_HALT_TOPIC_ARN", "arn:aws:sns:us-east-1:000000000000:acdl-sod-halt")
monkeypatch.setenv("ACDL_SOD_HALT_TOPIC_ARN", "arn:aws:sns:us-east-1:000000000000:nova-sod-halt")
sns_client = mock.MagicMock()
route_halt_artifact("contract-123", "SEPARATION_OF_DUTIES_VIOLATION: x==y",
oncall_client=sns_client)
sns_client.publish.assert_called_once()
call = sns_client.publish.call_args
assert call.kwargs["TopicArn"] == "arn:aws:sns:us-east-1:000000000000:acdl-sod-halt"
assert call.kwargs["TopicArn"] == "arn:aws:sns:us-east-1:000000000000:nova-sod-halt"
assert "contract-123" in call.kwargs["Message"]
assert "SEPARATION_OF_DUTIES_VIOLATION" in call.kwargs["Message"]
assert call.kwargs["Subject"] == "ACDL SoD halt"
@@ -54,7 +54,7 @@ def test_route_halt_outbox_fallback_writes_event(monkeypatch):
def test_route_halt_sns_failure_falls_back_to_outbox(monkeypatch):
"""If SNS publish raises, the outbox fallback is used."""
monkeypatch.setenv("ACDL_SOD_HALT_TOPIC_ARN", "arn:aws:sns:us-east-1:000000000000:acdl-sod-halt")
monkeypatch.setenv("ACDL_SOD_HALT_TOPIC_ARN", "arn:aws:sns:us-east-1:000000000000:nova-sod-halt")
sns_client = mock.MagicMock()
sns_client.publish.side_effect = Exception("SNS down")
with mock.patch("core.outbox_writer.write_event") as mock_write:
@@ -63,8 +63,8 @@ def test_route_halt_sns_failure_falls_back_to_outbox(monkeypatch):
def test_sns_topic_defined_in_terraform():
"""terraform/platform/main.tf defines the acdl-sod-halt SNS topic."""
"""terraform/platform/main.tf defines the nova-sod-halt SNS topic."""
tf = (ROOT / "terraform" / "platform" / "main.tf").read_text()
assert "aws_sns_topic" in tf
assert "acdl-sod-halt" in tf
assert "acdl_sod_halt_topic_arn" in tf
assert "nova-sod-halt" in tf
assert "nova_sod_halt_topic_arn" in tf
+6 -7
View File
@@ -107,12 +107,11 @@ class TestCreateStateBackend:
def test_state_bucket_name_construction(self, monkeypatch):
"""The state bucket name is derived from NOVA_AWS_ACCOUNT_ID
(P2 renamed from ACDL_AWS_ACCOUNT_ID; the bucket name acdl-tfstate-*
stays until P4, REQ-163)."""
(P4, REQ-163: bucket renamed acdl-tfstate-* nova-tfstate-*)."""
monkeypatch.setenv("NOVA_AWS_ACCOUNT_ID", "123456789012")
account_id = os.environ.get("NOVA_AWS_ACCOUNT_ID", "581513795199")
state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
assert state_bucket == "acdl-tfstate-123456789012-us-east-1"
state_bucket = f"nova-tfstate-{account_id}-us-east-1"
assert state_bucket == "nova-tfstate-123456789012-us-east-1"
def test_idempotent_bucket_creation(self, monkeypatch):
"""head_bucket success -> no create_bucket called."""
@@ -141,11 +140,11 @@ class TestCreateIamUser:
from unittest import mock
mock_iam = mock.MagicMock()
mock_iam.get_user.return_value = {"User": {"UserName": "acdl-spike-runner"}}
mock_iam.get_user.return_value = {"User": {"UserName": "nova-spike-runner"}}
monkeypatch.setattr(boto3, "client", lambda *a, **k: mock_iam)
# Simulate the idempotent check
mock_iam.get_user(UserName="acdl-spike-runner")
mock_iam.get_user(UserName="nova-spike-runner")
mock_iam.create_user.assert_not_called()
def test_policy_overwrite_is_idempotent(self, monkeypatch):
@@ -157,5 +156,5 @@ class TestCreateIamUser:
monkeypatch.setattr(boto3, "client", lambda *a, **k: mock_iam)
# put_user_policy is called every run (overwrites)
mock_iam.put_user_policy(UserName="acdl-spike-runner", PolicyName="p", PolicyDocument="{}")
mock_iam.put_user_policy(UserName="nova-spike-runner", PolicyName="p", PolicyDocument="{}")
mock_iam.put_user_policy.assert_called_once()