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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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-*`.
|
||||
@@ -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"
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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,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,3 +1,3 @@
|
||||
locals {
|
||||
alias_name = "alias/acdl-ci-kms"
|
||||
alias_name = "alias/nova-ci-kms"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
@@ -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,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 = [
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
variable "name" {
|
||||
type = string
|
||||
description = "WAF Web ACL name."
|
||||
default = "acdl-waf"
|
||||
default = "nova-waf"
|
||||
}
|
||||
|
||||
variable "scope" {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
#
|
||||
# Validated against schemas/deploy-pipeline.schema.json.
|
||||
|
||||
name: acdl-deploy
|
||||
name: nova-deploy
|
||||
environment: dev
|
||||
triggers:
|
||||
push: [main]
|
||||
|
||||
@@ -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())
|
||||
@@ -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}")
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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) |
|
||||
@@ -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"},
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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,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-*"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
+53
-53
@@ -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,12 +352,12 @@ 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."
|
||||
}
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user