diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index ee0b749..b1605ab 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ee0b749..b1605ab 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 \ No newline at end of file diff --git a/core/lambda/contract_ingestor.py b/core/lambda/contract_ingestor.py index 8e0a03b..53c1556 100644 --- a/core/lambda/contract_ingestor.py +++ b/core/lambda/contract_ingestor.py @@ -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. """ diff --git a/core/local_emulators.py b/core/local_emulators.py index 08b3bfa..5721171 100644 --- a/core/local_emulators.py +++ b/core/local_emulators.py @@ -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)) diff --git a/core/outbox_writer.py b/core/outbox_writer.py index 76e4a44..128a086 100644 --- a/core/outbox_writer.py +++ b/core/outbox_writer.py @@ -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 (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") diff --git a/core/regression_verify.py b/core/regression_verify.py index 55f84cd..cf3fc64 100755 --- a/core/regression_verify.py +++ b/core/regression_verify.py @@ -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), diff --git a/docs/NOVA_AWS_MIGRATION.md b/docs/NOVA_AWS_MIGRATION.md new file mode 100644 index 0000000..ce0303f --- /dev/null +++ b/docs/NOVA_AWS_MIGRATION.md @@ -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-` | `nova-deploy-` | 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 .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="" + export NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY="" + 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-` → `nova-deploy-` 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:///` 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-` + - 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-*`. \ No newline at end of file diff --git a/modules/l1/alb/instance.json b/modules/l1/alb/instance.json index b254689..1c68c76 100644 --- a/modules/l1/alb/instance.json +++ b/modules/l1/alb/instance.json @@ -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" diff --git a/modules/l1/cloudfront/terraform/locals.tf b/modules/l1/cloudfront/terraform/locals.tf index e3b8a41..864cbea 100644 --- a/modules/l1/cloudfront/terraform/locals.tf +++ b/modules/l1/cloudfront/terraform/locals.tf @@ -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" } diff --git a/modules/l1/ecs-cluster/terraform/variables.tf b/modules/l1/ecs-cluster/terraform/variables.tf index 2d4e8f7..addc305 100644 --- a/modules/l1/ecs-cluster/terraform/variables.tf +++ b/modules/l1/ecs-cluster/terraform/variables.tf @@ -1,7 +1,7 @@ variable "name" { type = string description = "ECS cluster name." - default = "acdl-cluster" + default = "nova-cluster" } variable "region" { diff --git a/modules/l1/ecs-service/terraform/main.tf b/modules/l1/ecs-service/terraform/main.tf index 5da3443..0940024 100644 --- a/modules/l1/ecs-service/terraform/main.tf +++ b/modules/l1/ecs-service/terraform/main.tf @@ -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" } diff --git a/modules/l1/iam-role/terraform/variables.tf b/modules/l1/iam-role/terraform/variables.tf index 65df80f..afb3684 100644 --- a/modules/l1/iam-role/terraform/variables.tf +++ b/modules/l1/iam-role/terraform/variables.tf @@ -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" { diff --git a/modules/l1/kms-key/terraform/locals.tf b/modules/l1/kms-key/terraform/locals.tf index feb6a3f..29590d0 100644 --- a/modules/l1/kms-key/terraform/locals.tf +++ b/modules/l1/kms-key/terraform/locals.tf @@ -1,3 +1,3 @@ locals { - alias_name = "alias/acdl-ci-kms" + alias_name = "alias/nova-ci-kms" } \ No newline at end of file diff --git a/modules/l1/rds/terraform/main.tf b/modules/l1/rds/terraform/main.tf index 7d94166..3b9a572 100644 --- a/modules/l1/rds/terraform/main.tf +++ b/modules/l1/rds/terraform/main.tf @@ -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) } diff --git a/modules/l1/s3/terraform/outputs.tf b/modules/l1/s3/terraform/outputs.tf index d1d605a..96b1fca 100644 --- a/modules/l1/s3/terraform/outputs.tf +++ b/modules/l1/s3/terraform/outputs.tf @@ -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)." } \ No newline at end of file diff --git a/modules/l1/uptime/terraform/main.tf b/modules/l1/uptime/terraform/main.tf index e80b12a..4eb0229 100644 --- a/modules/l1/uptime/terraform/main.tf +++ b/modules/l1/uptime/terraform/main.tf @@ -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 diff --git a/modules/l1/vpc/terraform/locals.tf b/modules/l1/vpc/terraform/locals.tf index 9389d4a..bf9e6a4 100644 --- a/modules/l1/vpc/terraform/locals.tf +++ b/modules/l1/vpc/terraform/locals.tf @@ -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 = [ diff --git a/modules/l1/vpc/terraform/variables.tf b/modules/l1/vpc/terraform/variables.tf index 8ffd52f..5c05b84 100644 --- a/modules/l1/vpc/terraform/variables.tf +++ b/modules/l1/vpc/terraform/variables.tf @@ -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" { diff --git a/modules/l1/waf/terraform/main.tf b/modules/l1/waf/terraform/main.tf index 700bb0d..d61cf19 100644 --- a/modules/l1/waf/terraform/main.tf +++ b/modules/l1/waf/terraform/main.tf @@ -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 } diff --git a/modules/l1/waf/terraform/variables.tf b/modules/l1/waf/terraform/variables.tf index 4760b7e..68d871e 100644 --- a/modules/l1/waf/terraform/variables.tf +++ b/modules/l1/waf/terraform/variables.tf @@ -1,7 +1,7 @@ variable "name" { type = string description = "WAF Web ACL name." - default = "acdl-waf" + default = "nova-waf" } variable "scope" { diff --git a/modules/l2/microservice/composition.json b/modules/l2/microservice/composition.json index b66cc57..b3c9812 100644 --- a/modules/l2/microservice/composition.json +++ b/modules/l2/microservice/composition.json @@ -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"}, diff --git a/pipelines/contract.yml b/pipelines/contract.yml index d3494ed..4d2a4cc 100644 --- a/pipelines/contract.yml +++ b/pipelines/contract.yml @@ -17,7 +17,7 @@ # # Validated against schemas/deploy-pipeline.schema.json. -name: acdl-deploy +name: nova-deploy environment: dev triggers: push: [main] diff --git a/scripts/migrate_dynamodb_data.py b/scripts/migrate_dynamodb_data.py new file mode 100644 index 0000000..6551173 --- /dev/null +++ b/scripts/migrate_dynamodb_data.py @@ -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()) \ No newline at end of file diff --git a/scripts/push_consumer_image.py b/scripts/push_consumer_image.py index e50524e..672346f 100644 --- a/scripts/push_consumer_image.py +++ b/scripts/push_consumer_image.py @@ -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}") diff --git a/scripts/rotate_spike_key.sh b/scripts/rotate_spike_key.sh index 2b0a38a..28e40f5 100755 --- a/scripts/rotate_spike_key.sh +++ b/scripts/rotate_spike_key.sh @@ -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). diff --git a/scripts/run_ci.sh b/scripts/run_ci.sh index 753f11c..fcc078c 100755 --- a/scripts/run_ci.sh +++ b/scripts/run_ci.sh @@ -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" diff --git a/terraform/bootstrap/README.md b/terraform/bootstrap/README.md index 86fd9d4..9a41e89 100644 --- a/terraform/bootstrap/README.md +++ b/terraform/bootstrap/README.md @@ -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="" - export ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY="" + export NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID="" + export NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY="" 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="" -export ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY="" +export NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID="" +export NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY="" 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) | \ No newline at end of file diff --git a/terraform/bootstrap/apply_iam_baseline.py b/terraform/bootstrap/apply_iam_baseline.py index dc5ed08..c38e09d 100644 --- a/terraform/bootstrap/apply_iam_baseline.py +++ b/terraform/bootstrap/apply_iam_baseline.py @@ -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"}, diff --git a/terraform/bootstrap/create_iam_user.py b/terraform/bootstrap/create_iam_user.py index d3fdb86..434e872 100644 --- a/terraform/bootstrap/create_iam_user.py +++ b/terraform/bootstrap/create_iam_user.py @@ -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") diff --git a/terraform/bootstrap/create_state_backend.py b/terraform/bootstrap/create_state_backend.py index c255794..90a711e 100644 --- a/terraform/bootstrap/create_state_backend.py +++ b/terraform/bootstrap/create_state_backend.py @@ -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--us-east-1 (versioning enabled). -- DynamoDB table acdl-outbox (PAY_PER_REQUEST; PK contractId, SK +- S3 bucket nova-tfstate--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(): diff --git a/terraform/bootstrap/spike_runner_policy.json b/terraform/bootstrap/spike_runner_policy.json index 67d59e3..4d206d5 100644 --- a/terraform/bootstrap/spike_runner_policy.json +++ b/terraform/bootstrap/spike_runner_policy.json @@ -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-*" } ] } diff --git a/terraform/ci-vpc/main.tf b/terraform/ci-vpc/main.tf index 4d6ebdd..cda92e8 100644 --- a/terraform/ci-vpc/main.tf +++ b/terraform/ci-vpc/main.tf @@ -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" { diff --git a/terraform/microservice/main.tf b/terraform/microservice/main.tf index c7ae60b..9a1a876 100644 --- a/terraform/microservice/main.tf +++ b/terraform/microservice/main.tf @@ -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 -} +} \ No newline at end of file diff --git a/terraform/microservice/terraform.tf b/terraform/microservice/terraform.tf index b2f128c..158027c 100644 --- a/terraform/microservice/terraform.tf +++ b/terraform/microservice/terraform.tf @@ -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" } diff --git a/terraform/platform/README.md b/terraform/platform/README.md index 1a554d4..dcd6eac 100644 --- a/terraform/platform/README.md +++ b/terraform/platform/README.md @@ -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 diff --git a/terraform/platform/consumer_invoke_policy.json b/terraform/platform/consumer_invoke_policy.json index c3e84fb..fd7a625 100644 --- a/terraform/platform/consumer_invoke_policy.json +++ b/terraform/platform/consumer_invoke_policy.json @@ -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}" diff --git a/terraform/platform/main.tf b/terraform/platform/main.tf index 50c5668..fd10cca 100644 --- a/terraform/platform/main.tf +++ b/terraform/platform/main.tf @@ -1,11 +1,11 @@ -# ACDL platform infrastructure — contract ingestion Lambda + DynamoDB (D-051) +# Nova platform infrastructure — contract ingestion Lambda + DynamoDB (D-051) # # Deploys: -# - DynamoDB table acdl-contracts (PK consumerRepo, SK contractId#submittedAt, SSE via CMK, PITR) +# - DynamoDB table nova-contracts (PK consumerRepo, SK contractId#submittedAt, SSE via CMK, PITR) # - KMS customer-managed key for DynamoDB + SSM (shared CMK) -# - Lambda function acdl-contract-ingestor (Python 3.12, handler contract_ingestor.lambda_handler) +# - Lambda function nova-contract-ingestor (Python 3.12, handler contract_ingestor.lambda_handler) # - Lambda Function URL (IAM auth — consumers invoke via SigV4) -# - Secrets Manager secret acdl/github-token (stores the Lambda's GitHub PAT for issue creation) +# - Secrets Manager secret nova/github-token (stores the Lambda's GitHub PAT for issue creation) # - IAM execution role for the Lambda (DynamoDB write + Secrets Manager read + KMS decrypt) # # State: terraform/platform/terraform.tfstate (separate from spike/ and microservice/) @@ -19,7 +19,7 @@ terraform { } } backend "s3" { - bucket = "acdl-tfstate-581513795199-us-east-1" + bucket = "nova-tfstate-581513795199-us-east-1" key = "platform/terraform.tfstate" region = "us-east-1" } @@ -37,20 +37,20 @@ variable "vpc_cidr" { } # KMS customer-managed key for DynamoDB SSE + SSM Parameter Store encryption -resource "aws_kms_key" "acdl_platform" { - description = "ACDL platform KMS key (DynamoDB SSE + SSM + Secrets Manager)" +resource "aws_kms_key" "nova_platform" { + description = "Nova platform KMS key (DynamoDB SSE + SSM + Secrets Manager)" enable_key_rotation = true deletion_window_in_days = 30 } -resource "aws_kms_alias" "acdl_platform" { - name = "alias/acdl-platform" - target_key_id = aws_kms_key.acdl_platform.key_id +resource "aws_kms_alias" "nova_platform" { + name = "alias/nova-platform" + target_key_id = aws_kms_key.nova_platform.key_id } # DynamoDB table for contract ingestion -resource "aws_dynamodb_table" "acdl_contracts" { - name = "acdl-contracts" +resource "aws_dynamodb_table" "nova_contracts" { + name = "nova-contracts" billing_mode = "PAY_PER_REQUEST" hash_key = "consumerRepo" range_key = "contractId#submittedAt" @@ -71,7 +71,7 @@ resource "aws_dynamodb_table" "acdl_contracts" { server_side_encryption { enabled = true - kms_key_arn = aws_kms_key.acdl_platform.arn + kms_key_arn = aws_kms_key.nova_platform.arn } tags = { @@ -84,9 +84,9 @@ resource "aws_dynamodb_table" "acdl_contracts" { # Secrets Manager secret for the Lambda's GitHub token (issue creation) resource "aws_secretsmanager_secret" "github_token" { - name = "acdl/github-token" + name = "nova/github-token" description = "GitHub PAT for the platform Lambda to create issues on the platform repo (D-055)." - kms_key_id = aws_kms_key.acdl_platform.arn + kms_key_id = aws_kms_key.nova_platform.arn tags = { "nova:owner" = "acdl" @@ -98,7 +98,7 @@ resource "aws_secretsmanager_secret" "github_token" { # IAM execution role for the Lambda resource "aws_iam_role" "lambda_exec" { - name = "acdl-contract-ingestor-role" + name = "nova-contract-ingestor-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ @@ -110,7 +110,7 @@ resource "aws_iam_role" "lambda_exec" { } resource "aws_iam_role_policy" "lambda_permissions" { - name = "acdl-contract-ingestor-policy" + name = "nova-contract-ingestor-policy" role = aws_iam_role.lambda_exec.id policy = jsonencode({ Version = "2012-10-17" @@ -118,12 +118,12 @@ resource "aws_iam_role_policy" "lambda_permissions" { { Effect = "Allow" Action = ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:Query", "dynamodb:UpdateItem"] - Resource = aws_dynamodb_table.acdl_contracts.arn + Resource = aws_dynamodb_table.nova_contracts.arn }, { Effect = "Allow" Action = ["dynamodb:GetItem", "dynamodb:Query"] - Resource = aws_dynamodb_table.acdl_change_requests.arn + Resource = aws_dynamodb_table.nova_change_requests.arn }, { Effect = "Allow" @@ -133,7 +133,7 @@ resource "aws_iam_role_policy" "lambda_permissions" { { Effect = "Allow" Action = ["kms:Decrypt"] - Resource = aws_kms_key.acdl_platform.arn + Resource = aws_kms_key.nova_platform.arn }, { Effect = "Allow" @@ -152,7 +152,7 @@ locals { resource "aws_lambda_function" "contract_ingestor" { count = local.lambda_zip_exists ? 1 : 0 - function_name = "acdl-contract-ingestor" + function_name = "nova-contract-ingestor" handler = "contract_ingestor.lambda_handler" runtime = "python3.12" role = aws_iam_role.lambda_exec.arn @@ -161,9 +161,9 @@ resource "aws_lambda_function" "contract_ingestor" { environment { variables = { - CONTRACTS_TABLE = aws_dynamodb_table.acdl_contracts.name + CONTRACTS_TABLE = aws_dynamodb_table.nova_contracts.name GITHUB_TOKEN_SECRET_ID = aws_secretsmanager_secret.github_token.name - PLATFORM_REPO = "acdl/acdl" + PLATFORM_REPO = "nova/acdl" } } @@ -204,8 +204,8 @@ output "consumer_invoke_policy_rendered" { } # REQ-93: DynamoDB table for change requests (CMDB for decommission validation) -resource "aws_dynamodb_table" "acdl_change_requests" { - name = "acdl-change-requests" +resource "aws_dynamodb_table" "nova_change_requests" { + name = "nova-change-requests" billing_mode = "PAY_PER_REQUEST" hash_key = "changeRequestId" range_key = "submittedAt" @@ -226,7 +226,7 @@ resource "aws_dynamodb_table" "acdl_change_requests" { server_side_encryption { enabled = true - kms_key_arn = aws_kms_key.acdl_platform.arn + kms_key_arn = aws_kms_key.nova_platform.arn } tags = { @@ -237,10 +237,10 @@ resource "aws_dynamodb_table" "acdl_change_requests" { } } # REQ-107: SNS topic for separation-of-duties halt artifacts. -# route_halt_artifact publishes here when ACDL_SOD_HALT_TOPIC_ARN is set. -resource "aws_sns_topic" "acdl_sod_halt" { - name = "acdl-sod-halt" - kms_master_key_id = aws_kms_key.acdl_platform.id +# route_halt_artifact publishes here when NOVA_SOD_HALT_TOPIC_ARN is set. +resource "aws_sns_topic" "nova_sod_halt" { + name = "nova-sod-halt" + kms_master_key_id = aws_kms_key.nova_platform.id tags = { "nova:owner" = "acdl" "nova:contract" = "platform" @@ -249,8 +249,8 @@ resource "aws_sns_topic" "acdl_sod_halt" { } } -output "acdl_sod_halt_topic_arn" { - value = aws_sns_topic.acdl_sod_halt.arn +output "nova_sod_halt_topic_arn" { + value = aws_sns_topic.nova_sod_halt.arn } # --------------------------------------------------------------------------- @@ -258,10 +258,10 @@ output "acdl_sod_halt_topic_arn" { # via terraform_remote_state (data source). No per-contract VPC ever again. # --------------------------------------------------------------------------- -resource "aws_vpc" "acdl_shared" { +resource "aws_vpc" "nova_shared" { cidr_block = var.vpc_cidr tags = { - Name = "acdl-shared" + Name = "nova-shared" "nova:owner" = "acdl" "nova:contract" = "platform" "nova:environment" = "shared" @@ -269,13 +269,13 @@ resource "aws_vpc" "acdl_shared" { } } -resource "aws_subnet" "acdl_shared" { +resource "aws_subnet" "nova_shared" { count = length(data.aws_availability_zones.available.names) - vpc_id = aws_vpc.acdl_shared.id - cidr_block = cidrsubnet(aws_vpc.acdl_shared.cidr_block, 8, count.index + 1) + vpc_id = aws_vpc.nova_shared.id + cidr_block = cidrsubnet(aws_vpc.nova_shared.cidr_block, 8, count.index + 1) availability_zone = data.aws_availability_zones.available.names[count.index] tags = { - Name = "acdl-shared-subnet-${count.index}" + Name = "nova-shared-subnet-${count.index}" "nova:owner" = "acdl" "nova:contract" = "platform" "nova:environment" = "shared" @@ -287,10 +287,10 @@ data "aws_availability_zones" "available" { state = "available" } -resource "aws_internet_gateway" "acdl_shared" { - vpc_id = aws_vpc.acdl_shared.id +resource "aws_internet_gateway" "nova_shared" { + vpc_id = aws_vpc.nova_shared.id tags = { - Name = "acdl-shared-igw" + Name = "nova-shared-igw" "nova:owner" = "acdl" "nova:contract" = "platform" "nova:environment" = "shared" @@ -298,14 +298,14 @@ resource "aws_internet_gateway" "acdl_shared" { } } -resource "aws_route_table" "acdl_shared" { - vpc_id = aws_vpc.acdl_shared.id +resource "aws_route_table" "nova_shared" { + vpc_id = aws_vpc.nova_shared.id route { cidr_block = "0.0.0.0/0" - gateway_id = aws_internet_gateway.acdl_shared.id + gateway_id = aws_internet_gateway.nova_shared.id } tags = { - Name = "acdl-shared-rt" + Name = "nova-shared-rt" "nova:owner" = "acdl" "nova:contract" = "platform" "nova:environment" = "shared" @@ -313,16 +313,16 @@ resource "aws_route_table" "acdl_shared" { } } -resource "aws_route_table_association" "acdl_shared" { +resource "aws_route_table_association" "nova_shared" { count = 2 - subnet_id = aws_subnet.acdl_shared[count.index].id - route_table_id = aws_route_table.acdl_shared.id + subnet_id = aws_subnet.nova_shared[count.index].id + route_table_id = aws_route_table.nova_shared.id } resource "aws_security_group" "ecs" { - name = "acdl-ecs-sg" + name = "nova-ecs-sg" description = "Security group for ECS Fargate services (platform VPC)" - vpc_id = aws_vpc.acdl_shared.id + vpc_id = aws_vpc.nova_shared.id # Ingress on port 80 is open to 0.0.0.0/0 — this is acceptable because # the ECS service is fronted by a public-facing ALB (the ALB terminates @@ -343,7 +343,7 @@ resource "aws_security_group" "ecs" { } tags = { - Name = "acdl-ecs-sg" + Name = "nova-ecs-sg" "nova:owner" = "acdl" "nova:contract" = "platform" "nova:environment" = "shared" @@ -352,16 +352,16 @@ resource "aws_security_group" "ecs" { } output "vpc_id" { - value = aws_vpc.acdl_shared.id + value = aws_vpc.nova_shared.id description = "The shared platform VPC ID. Consumer stacks reference this via terraform_remote_state." } output "subnet_ids" { - value = join(",", aws_subnet.acdl_shared[*].id) + value = join(",", aws_subnet.nova_shared[*].id) description = "Comma-separated subnet IDs in the shared platform VPC." } output "ecs_security_group_id" { value = aws_security_group.ecs.id description = "Security group ID for ECS Fargate services in the platform VPC." -} +} \ No newline at end of file diff --git a/tests/test_contract_ingestor.py b/tests/test_contract_ingestor.py index 0ec683c..ec95ec2 100644 --- a/tests/test_contract_ingestor.py +++ b/tests/test_contract_ingestor.py @@ -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"}, diff --git a/tests/test_contract_resolver.py b/tests/test_contract_resolver.py index cf673fa..cf90773 100644 --- a/tests/test_contract_resolver.py +++ b/tests/test_contract_resolver.py @@ -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", }, diff --git a/tests/test_iam_policy_baseline.py b/tests/test_iam_policy_baseline.py index 1ff7831..6b9dd36 100644 --- a/tests/test_iam_policy_baseline.py +++ b/tests/test_iam_policy_baseline.py @@ -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 \ No newline at end of file + assert res == "*" or "nova" in res \ No newline at end of file diff --git a/tests/test_local_emulating_adapters.py b/tests/test_local_emulating_adapters.py index c164b05..fe94913 100644 --- a/tests/test_local_emulating_adapters.py +++ b/tests/test_local_emulating_adapters.py @@ -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") diff --git a/tests/test_migrate_dynamodb_data.py b/tests/test_migrate_dynamodb_data.py new file mode 100644 index 0000000..103a988 --- /dev/null +++ b/tests/test_migrate_dynamodb_data.py @@ -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 \ No newline at end of file diff --git a/tests/test_outbox_writer.py b/tests/test_outbox_writer.py index 0c49110..3db95ec 100644 --- a/tests/test_outbox_writer.py +++ b/tests/test_outbox_writer.py @@ -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"}, diff --git a/tests/test_output_publisher.py b/tests/test_output_publisher.py index 847811e..85c1ac5 100644 --- a/tests/test_output_publisher.py +++ b/tests/test_output_publisher.py @@ -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 diff --git a/tests/test_route_halt_artifact.py b/tests/test_route_halt_artifact.py index a065a00..a8e3f1b 100644 --- a/tests/test_route_halt_artifact.py +++ b/tests/test_route_halt_artifact.py @@ -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 \ No newline at end of file + assert "nova-sod-halt" in tf + assert "nova_sod_halt_topic_arn" in tf \ No newline at end of file diff --git a/tests/test_untested_scripts.py b/tests/test_untested_scripts.py index e8587d0..c07ce3a 100644 --- a/tests/test_untested_scripts.py +++ b/tests/test_untested_scripts.py @@ -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() \ No newline at end of file