feat(P2): Nova rebrand — code/env-vars/consumer-path (REQ-158/159/160)

core/env.py dual-read helper (D-108); 21 ACDL_*→NOVA_* env vars migrated
across core/scripts/adapters/tests/workflows + .env/.env.secrets (key
rename, values stay). G-106 binding: run_platform.sh:288-289 +
regression_verify.py:309-312 dual-read (NOVA first, ACDL fallback).
G-108 binding: Gitea NOVA_* secrets created via API + workflow secrets:
refs updated (deploy.yml + modules-lifecycle.yml, .gitea + .github).
acdl_tagging.py→nova_tagging.py (D-109 warn mode, nova:* enforced).
.acdl/→.nova/ consumer path (resolver + deploy workflow + schema +
tests + docs). Test fixtures updated; pytest + run_ci.sh PASS.

---ci---
project: acdl
phase: 2
milestone: v1.15
status: execute
---/ci---
This commit is contained in:
Jon Chery
2026-07-30 01:25:24 +00:00
parent 0bc70a3d95
commit d5bae868a4
48 changed files with 593 additions and 297 deletions
+11 -7
View File
@@ -26,7 +26,7 @@
# platform log) for auditability.
#
# Inputs:
# contract — path to the consumer's contract YAML (default .acdl/contract.yml)
# contract — path to the consumer's contract YAML (default .nova/contract.yml)
# mode — full | plan-only | check-only (default full; dev = full apply,
# higher environments hold for HITL — the calling repo or the
# forge environment gate enforces that)
@@ -39,7 +39,7 @@
# policy restricts view/update to resources tagged acdl:owner=<consumer-repo>.
#
# Override (where OIDC is unavailable, e.g. Gitea pending
# go-gitea/gitea#36988): set ACDL_AWS_ACCESS_KEY_ID + ACDL_AWS_SECRET_ACCESS_KEY
# go-gitea/gitea#36988): set NOVA_AWS_ACCESS_KEY_ID + NOVA_AWS_SECRET_ACCESS_KEY
# 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.
@@ -51,7 +51,7 @@ on:
contract:
description: Path to the consumer contract YAML (in the consumer repo)
type: string
default: .acdl/contract.yml
default: .nova/contract.yml
mode:
description: Pipeline mode — full (apply), plan-only, check-only, or decommission
type: string
@@ -102,10 +102,14 @@ jobs:
- name: Configure AWS credentials (OIDC default + static-key override)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/acdl-deploy-{1}', secrets.ACDL_AWS_ACCOUNT_ID, github.repository_id) || '' }}
# 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) || '' }}
aws-region: us-east-1
access-key-id: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
secret-access-key: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
- name: Run the platform pipeline
working-directory: ${{ github.workspace }}
@@ -145,7 +149,7 @@ jobs:
AWS_DEFAULT_REGION: us-east-1
run: |
aws lambda invoke-function-url \
--function-url "${{ secrets.ACDL_LAMBDA_URL }}" \
--function-url "${{ secrets.NOVA_LAMBDA_URL }}" \
--cli-binary-format raw-in-base64-out \
--payload "$(python3 -c "import json,os; print(json.dumps({'action':'report_error','consumerRepo':os.environ.get('GITHUB_REPOSITORY',''),'contractId':'${{ github.run_id }}','error':'Deploy pipeline failed. See run logs.','runUrl':'${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}','environment':'dev'}))")" \
/dev/null || true
+27 -27
View File
@@ -15,7 +15,7 @@
# "plan" — the lifecycle scripts run `run_platform.sh --plan-only` (fast,
# no AWS mutation, validates the contract->resolver->adapter->plan chain
# for every module on every PR, with no AWS credentials or cost). Set to
# "full" via workflow_dispatch (or the ACDL_LIFECYCLE_MODE repo variable)
# "full" via workflow_dispatch (or the NOVA_LIFECYCLE_MODE repo variable)
# to run the real apply→modify→destroy against live AWS. In plan mode the
# short-lived CI VPC apply/destroy jobs are skipped (nothing is applied).
#
@@ -49,7 +49,7 @@ jobs:
ci-vpc-apply:
name: CI VPC apply
runs-on: ubuntu-latest
if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.ACDL_LIFECYCLE_MODE != 'plan' }}
if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
steps:
- uses: actions/checkout@v4
- name: Install Terraform 1.9.*
@@ -60,8 +60,8 @@ jobs:
- name: Apply CI VPC
working-directory: terraform/ci-vpc
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
terraform init -input=false -lock=false
@@ -78,7 +78,7 @@ jobs:
matrix:
module: [s3, kms-key, ecr, ecs-cluster, iam-role, cloudfront, waf, vpc, alb, ecs-service, rds, uptime]
env:
ACDL_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.ACDL_LIFECYCLE_MODE || 'plan' }}
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
steps:
- uses: actions/checkout@v4
- name: Free disk space
@@ -97,31 +97,31 @@ jobs:
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
- name: Read CI VPC outputs
if: ${{ env.ACDL_LIFECYCLE_MODE == 'full' }}
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
working-directory: terraform/ci-vpc
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
terraform init -input=false -lock=false
terraform output -json > /tmp/ci-vpc-outputs.json
- name: Apply (simple)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
- name: Modify (complex)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
- name: Destroy
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
@@ -136,7 +136,7 @@ jobs:
matrix:
module: [static-assets, microservice]
env:
ACDL_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.ACDL_LIFECYCLE_MODE || 'plan' }}
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
steps:
- uses: actions/checkout@v4
- name: Free disk space
@@ -155,31 +155,31 @@ jobs:
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
- name: Read CI VPC outputs
if: ${{ env.ACDL_LIFECYCLE_MODE == 'full' }}
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
working-directory: terraform/ci-vpc
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
terraform init -input=false -lock=false
terraform output -json > /tmp/ci-vpc-outputs.json
- name: Apply (simple)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
- name: Modify (complex)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
- name: Destroy
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_l2_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
@@ -188,7 +188,7 @@ jobs:
name: CI VPC destroy
needs: [lifecycle, l2-lifecycle]
runs-on: ubuntu-latest
if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.ACDL_LIFECYCLE_MODE != 'plan' }}
if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
steps:
- uses: actions/checkout@v4
- name: Install Terraform 1.9.*
@@ -199,8 +199,8 @@ jobs:
- name: Destroy CI VPC
working-directory: terraform/ci-vpc
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
terraform init -input=false -lock=false
+11 -7
View File
@@ -26,7 +26,7 @@
# platform log) for auditability.
#
# Inputs:
# contract — path to the consumer's contract YAML (default .acdl/contract.yml)
# contract — path to the consumer's contract YAML (default .nova/contract.yml)
# mode — full | plan-only | check-only (default full; dev = full apply,
# higher environments hold for HITL — the calling repo or the
# forge environment gate enforces that)
@@ -39,7 +39,7 @@
# policy restricts view/update to resources tagged acdl:owner=<consumer-repo>.
#
# Override (where OIDC is unavailable, e.g. Gitea pending
# go-gitea/gitea#36988): set ACDL_AWS_ACCESS_KEY_ID + ACDL_AWS_SECRET_ACCESS_KEY
# go-gitea/gitea#36988): set NOVA_AWS_ACCESS_KEY_ID + NOVA_AWS_SECRET_ACCESS_KEY
# 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.
@@ -51,7 +51,7 @@ on:
contract:
description: Path to the consumer contract YAML (in the consumer repo)
type: string
default: .acdl/contract.yml
default: .nova/contract.yml
mode:
description: Pipeline mode — full (apply), plan-only, check-only, or decommission
type: string
@@ -102,10 +102,14 @@ jobs:
- name: Configure AWS credentials (OIDC default + static-key override)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/acdl-deploy-{1}', secrets.ACDL_AWS_ACCOUNT_ID, github.repository_id) || '' }}
# 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) || '' }}
aws-region: us-east-1
access-key-id: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
secret-access-key: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
- name: Run the platform pipeline
working-directory: ${{ github.workspace }}
@@ -145,7 +149,7 @@ jobs:
AWS_DEFAULT_REGION: us-east-1
run: |
aws lambda invoke-function-url \
--function-url "${{ secrets.ACDL_LAMBDA_URL }}" \
--function-url "${{ secrets.NOVA_LAMBDA_URL }}" \
--cli-binary-format raw-in-base64-out \
--payload "$(python3 -c "import json,os; print(json.dumps({'action':'report_error','consumerRepo':os.environ.get('GITHUB_REPOSITORY',''),'contractId':'${{ github.run_id }}','error':'Deploy pipeline failed. See run logs.','runUrl':'${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}','environment':'dev'}))")" \
/dev/null || true
+27 -27
View File
@@ -15,7 +15,7 @@
# "plan" — the lifecycle scripts run `run_platform.sh --plan-only` (fast,
# no AWS mutation, validates the contract->resolver->adapter->plan chain
# for every module on every PR, with no AWS credentials or cost). Set to
# "full" via workflow_dispatch (or the ACDL_LIFECYCLE_MODE repo variable)
# "full" via workflow_dispatch (or the NOVA_LIFECYCLE_MODE repo variable)
# to run the real apply→modify→destroy against live AWS. In plan mode the
# short-lived CI VPC apply/destroy jobs are skipped (nothing is applied).
#
@@ -49,7 +49,7 @@ jobs:
ci-vpc-apply:
name: CI VPC apply
runs-on: ubuntu-latest
if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.ACDL_LIFECYCLE_MODE != 'plan' }}
if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
steps:
- uses: actions/checkout@v4
- name: Install Terraform 1.9.*
@@ -60,8 +60,8 @@ jobs:
- name: Apply CI VPC
working-directory: terraform/ci-vpc
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
terraform init -input=false -lock=false
@@ -78,7 +78,7 @@ jobs:
matrix:
module: [s3, kms-key, ecr, ecs-cluster, iam-role, cloudfront, waf, vpc, alb, ecs-service, rds, uptime]
env:
ACDL_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.ACDL_LIFECYCLE_MODE || 'plan' }}
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
steps:
- uses: actions/checkout@v4
- name: Free disk space
@@ -97,31 +97,31 @@ jobs:
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
- name: Read CI VPC outputs
if: ${{ env.ACDL_LIFECYCLE_MODE == 'full' }}
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
working-directory: terraform/ci-vpc
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
terraform init -input=false -lock=false
terraform output -json > /tmp/ci-vpc-outputs.json
- name: Apply (simple)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
- name: Modify (complex)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
- name: Destroy
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
@@ -136,7 +136,7 @@ jobs:
matrix:
module: [static-assets, microservice]
env:
ACDL_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.ACDL_LIFECYCLE_MODE || 'plan' }}
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
steps:
- uses: actions/checkout@v4
- name: Free disk space
@@ -155,31 +155,31 @@ jobs:
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
- name: Read CI VPC outputs
if: ${{ env.ACDL_LIFECYCLE_MODE == 'full' }}
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
working-directory: terraform/ci-vpc
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
terraform init -input=false -lock=false
terraform output -json > /tmp/ci-vpc-outputs.json
- name: Apply (simple)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
- name: Modify (complex)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
- name: Destroy
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_l2_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
@@ -188,7 +188,7 @@ jobs:
name: CI VPC destroy
needs: [lifecycle, l2-lifecycle]
runs-on: ubuntu-latest
if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.ACDL_LIFECYCLE_MODE != 'plan' }}
if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
steps:
- uses: actions/checkout@v4
- name: Install Terraform 1.9.*
@@ -199,8 +199,8 @@ jobs:
- name: Destroy CI VPC
working-directory: terraform/ci-vpc
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
terraform init -input=false -lock=false
+6 -5
View File
@@ -10,9 +10,10 @@ lives in the per-module terraform/ subdir, NOT in this file.
CLI: adapter.py <instance.json> <out_dir>
"""
import json
import os
import sys
import json, os, sys
_R = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, _R) if _R not in sys.path else None
from core import env
def _load_registry(repo_root):
@@ -112,7 +113,7 @@ def adapt(stack_instance, out_dir):
stack_name = stack.get("name", "spike")
environment = stack.get("environment", "dev")
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
account_id = env.get_env("AWS_ACCOUNT_ID", "581513795199")
state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
terraform_tf = (
'terraform {\n'
@@ -134,7 +135,7 @@ def adapt(stack_instance, out_dir):
data_source_names = stack_instance.get("data_sources", [])
parts = []
if data_source_names:
remote_state_key = os.environ.get("ACDL_REMOTE_STATE_KEY", "platform/terraform.tfstate")
remote_state_key = env.get_env("REMOTE_STATE_KEY", "platform/terraform.tfstate")
parts.append(
'data "terraform_remote_state" "platform" {\n'
' backend = "s3"\n'
+14 -9
View File
@@ -1,4 +1,4 @@
"""Translate Checkov JSON output to ACDL PolicyCheckResult records.
"""Translate Checkov JSON output to Nova PolicyCheckResult records.
Reads Checkov's JSON output (one framework key, e.g. terraform_plan),
emits a list of PolicyCheckResult dicts conforming to
@@ -6,10 +6,13 @@ schemas/policy_check_result.schema.json. Run Checkov with --soft-fail so
Checkov never exits non-zero; the confidence signal decides the gate, not
Checkov's exit code.
The ACDL tagging standard (D-054, D-043 closure) is enforced by a custom
Checkov rule at adapters/terraform/policy/custom_rules/acdl_tagging.py,
loaded via --external-checks-dir. The adapter therefore maps
ACDL_TAG_NAMING as a real rule (no synthetic SKIPPED record is emitted).
The Nova tagging standard (D-054, D-043 closure, D-109 warn mode in P2)
is enforced by a custom Checkov rule at
adapters/terraform/policy/custom_rules/nova_tagging.py, loaded via
--external-checks-dir. The adapter therefore maps NOVA_TAG_NAMING as a
real rule (no synthetic SKIPPED record is emitted). Renamed from
ACDL_TAG_NAMING in P2 (REQ-158); the rule is in warn mode for P2
(legacy acdl:* tag-key values stay until P3).
"""
import datetime
@@ -29,10 +32,12 @@ RULE_MAP = {
"CKV_AWS_40": ("iam-wildcard", "medium"),
"CKV_AWS_7": ("kms-key-reference", "medium"),
"CKV_AWS_33": ("kms-key-reference", "medium"),
# D-054 / D-043 closure: ACDL_TAG_NAMING is now a real custom Checkov
# rule (adapters/terraform/policy/custom_rules/acdl_tagging.py), loaded
# via --external-checks-dir. No synthetic SKIPPED record is emitted.
"ACDL_TAG_NAMING": ("tagging-standard", "medium"),
# D-054 / D-043 closure, D-109 warn mode (P2): NOVA_TAG_NAMING is a real
# custom Checkov rule (adapters/terraform/policy/custom_rules/nova_tagging.py),
# loaded via --external-checks-dir. No synthetic SKIPPED record is emitted.
# Renamed from ACDL_TAG_NAMING in P2 (REQ-158). Warn mode treats legacy
# acdl:*-only tags as a warning (P3 flips to hard-fail).
"NOVA_TAG_NAMING": ("tagging-standard", "medium"),
}
_RESULT_MAP = {"PASSED": "pass", "FAILED": "fail", "SKIPPED": "skipped"}
@@ -1,16 +1,24 @@
# ACDL Custom Checkov Rules
# Nova Custom Checkov Rules
This directory holds ACDL-authored Checkov custom rules, written in the
This directory holds Nova-authored Checkov custom rules, written in the
[Checkov Python custom-rule framework](https://www.checkov.io/4.Contributing/Custom%20Policies.html).
## Files
- `acdl_tagging.py``ACDL_TAG_NAMING` (D-054): ensures every taggable AWS
resource carries the four required ACDL tags
(`acdl:owner`, `acdl:contract`, `acdl:environment`, `acdl:cost-center`).
This rule replaces the synthetic SKIPPED `ACDL_TAG_NAMING` record that the
Checkov adapter previously emitted (D-043 closure). The canonical tag set
is declared in [`schemas/tagging-standard.json`](../../../schemas/tagging-standard.json).
- `nova_tagging.py``NOVA_TAG_NAMING` (D-054, D-109 warn mode in P2):
ensures every taggable AWS resource carries the four required Nova tags
(`nova:owner`, `nova:contract`, `nova:environment`, `nova:cost-center`).
This rule replaces the synthetic SKIPPED `NOVA_TAG_NAMING` record that the
Checkov adapter previously emitted (D-043 closure). Renamed from
`acdl_tagging.py` / `ACDL_TAG_NAMING` in P2 (REQ-158). The canonical tag
set is declared in [`schemas/tagging-standard.json`](../../../schemas/tagging-standard.json).
**P2 warn mode (D-109):** existing resources still carry `acdl:*` tag-key
values (left for P3). When a resource has only `acdl:*`-style tags and no
`nova:*` tags, the rule logs a WARNING instead of failing, so the
regression gate stays green during the parallel-tag transition window.
P3 flips to hard-fail once `nova:*` tags are emitted in parallel and the
ABAC policy is swapped.
## How Checkov loads them
@@ -23,12 +31,12 @@ checkov -f terraform/spike/main.tf --framework terraform -o json --soft-fail \
```
Checkov imports each `*.py` file in the directory and instantiates the
module-level `check` object (see the `check = AcdlTaggingStandard()` line at
the bottom of `acdl_tagging.py`).
module-level `check` object (see the `check = NovaTaggingStandard()` line at
the bottom of `nova_tagging.py`).
## Severity / result mapping
The Checkov adapter (`adapters/terraform/policy/checkov_adapter.py`)
maps `ACDL_TAG_NAMING` to `(tagging-standard, medium)` in `RULE_MAP`. The
maps `NOVA_TAG_NAMING` to `(tagging-standard, medium)` in `RULE_MAP`. The
custom rule therefore produces real `PASS`/`FAIL` PolicyCheckResult records,
feeding the confidence signal instead of the old SKIPPED placeholder.
@@ -1,54 +0,0 @@
"""ACDL tagging standard custom Checkov rule (D-054).
Checks that all taggable AWS resources have the required ACDL tags:
acdl:owner, acdl:contract, acdl:environment, acdl:cost-center
Fails (severity medium) when any required tag is missing.
Closes the D-043 deferral (the SKIPPED ACDL_TAG_NAMING placeholder
becomes a real check).
"""
from __future__ import annotations
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
from checkov.common.models.enums import CheckResult, CheckCategories
REQUIRED_TAGS = ("acdl:owner", "acdl:contract", "acdl:environment", "acdl:cost-center")
# Resources that support tags (exclude resources that have no tags attribute)
NON_TAGGABLE_TYPES = (
"aws_cloudfront_origin_access_control",
"aws_lambda_function_url",
"aws_route_table_association",
"aws_internet_gateway",
)
class AcdlTaggingStandard(BaseResourceCheck):
def __init__(self):
name = "Ensure all taggable AWS resources have required ACDL tags"
check_id = "ACDL_TAG_NAMING"
supported_resources = ["*"] # all resources
categories = [CheckCategories.GENERAL_SECURITY]
super().__init__(name=name, check_id=check_id, categories=categories, supported_resources=supported_resources)
def scan_resource_conf(self, conf, entity_type):
# Skip non-taggable resources
if entity_type in NON_TAGGABLE_TYPES:
return CheckResult.PASSED
# Check for a tags block
tags = conf.get("tags")
if not tags:
return CheckResult.FAILED
tag_keys = set()
if isinstance(tags, list) and tags:
tag_block = tags[0]
if isinstance(tag_block, dict):
tag_keys = set(tag_block.keys())
elif isinstance(tags, dict):
tag_keys = set(tags.keys())
missing = [t for t in REQUIRED_TAGS if t not in tag_keys]
if missing:
return CheckResult.FAILED
return CheckResult.PASSED
check = AcdlTaggingStandard()
@@ -0,0 +1,88 @@
"""Nova tagging standard custom Checkov rule (D-054, D-109 warn mode).
Checks that all taggable AWS resources have the required Nova tags:
nova:owner, nova:contract, nova:environment, nova:cost-center
In **warn mode** (P2, REQ-158): existing resources still carry `acdl:*`
tags (the legacy tag-key VALUES stay until P3). When a resource has
only `acdl:*`-style tags and no `nova:*` tags, the rule logs a WARNING
instead of failing, so the regression gate stays green during the
parallel-tag transition window. P3 flips this to hard-fail (D-109 hard
mode) once `nova:*` tags are emitted in parallel and the ABAC policy is
swapped.
Closes the D-043 deferral (the SKIPPED NOVA_TAG_NAMING placeholder
becomes a real check). Renamed from acdl_tagging.py in P2 (REQ-158);
the Checkov rule ID ACDL_TAG_NAMING → NOVA_TAG_NAMING.
"""
from __future__ import annotations
import sys
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
from checkov.common.models.enums import CheckResult, CheckCategories
REQUIRED_TAGS = ("nova:owner", "nova:contract", "nova:environment", "nova:cost-center")
# Legacy acdl:* tag keys — the parallel-tag period (P3) emits both nova:*
# and acdl:*; P2 warn mode treats acdl:*-only tags as a warning, not a
# failure. The acdl:* VALUES in tagging-standard.json are left for P3.
LEGACY_TAGS = ("acdl:owner", "acdl:contract", "acdl:environment", "acdl:cost-center")
# Resources that support tags (exclude resources that have no tags attribute)
NON_TAGGABLE_TYPES = (
"aws_cloudfront_origin_access_control",
"aws_lambda_function_url",
"aws_route_table_association",
"aws_internet_gateway",
)
# P2 warn mode (D-109): emit a warning (not a hard FAIL) when a resource
# carries only legacy acdl:* tags and no nova:* tags. P3 flips this to
# False (hard-fail). Set NOVA_TAGGING_HARD=1 to opt into hard mode early
# (used by P3 tests before the P3 flip lands).
_WARN_MODE = True
class NovaTaggingStandard(BaseResourceCheck):
def __init__(self):
name = "Ensure all taggable AWS resources have required Nova tags"
check_id = "NOVA_TAG_NAMING"
supported_resources = ["*"] # all resources
categories = [CheckCategories.GENERAL_SECURITY]
super().__init__(name=name, check_id=check_id, categories=categories, supported_resources=supported_resources)
def scan_resource_conf(self, conf, entity_type):
# Skip non-taggable resources
if entity_type in NON_TAGGABLE_TYPES:
return CheckResult.PASSED
# Check for a tags block
tags = conf.get("tags")
if not tags:
return CheckResult.FAILED
tag_keys = set()
if isinstance(tags, list) and tags:
tag_block = tags[0]
if isinstance(tag_block, dict):
tag_keys = set(tag_block.keys())
elif isinstance(tags, dict):
tag_keys = set(tags.keys())
missing = [t for t in REQUIRED_TAGS if t not in tag_keys]
if not missing:
return CheckResult.PASSED
# Warn mode (D-109, P2): if the resource carries the legacy acdl:*
# tag keys for every required tag, emit a warning rather than a
# hard fail — existing resources still carry acdl:* until P3.
if _WARN_MODE:
has_all_legacy = all(t in tag_keys for t in LEGACY_TAGS)
if has_all_legacy:
sys.stderr.write(
f"[nova_tagging] WARN: {entity_type} has legacy acdl:* tags "
f"but no nova:* tags (P2 warn mode, D-109). Migrate to "
f"nova:* tags before P5.\n"
)
return CheckResult.PASSED
return CheckResult.FAILED
check = NovaTaggingStandard()
+14 -4
View File
@@ -14,7 +14,8 @@ concerns split into two tiers:
The operator-supplied evidence artifact is a JSON blob with `timestamp`,
`type`, `payload`, and an optional `signature` (JWS detached). Freshness
is validated against the window from §10.4. Signature verification runs
when `ACDL_ATTESTATION_SIGNING_KEY_ID` is set; it is skipped + logged
when `NOVA_ATTESTATION_SIGNING_KEY_ID` is set (dual-read via core/env.py:
NOVA_* preferred, ACDL_* fallback until P5); it is skipped + logged
when unset (dev/CI — D-089). The matrix fails loud if an operator-supplied
concern is missing or expired for prod/dr.
"""
@@ -24,6 +25,14 @@ import os
import sys
from typing import Optional, Tuple
# Repo root on sys.path so `from core import env` resolves to THIS package
# when run as a script (avoids editable-installed third-party `core` shadow).
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env
# Freshness windows (days) from hitl_matrix_design.md §10.4.
FRESHNESS_DAYS = {
@@ -81,14 +90,15 @@ def _is_fresh(artifact: dict, concern: str) -> bool:
def _verify_signature(artifact: dict) -> bool:
"""Verify the JWS detached signature when ACDL_ATTESTATION_SIGNING_KEY_ID is set.
"""Verify the JWS detached signature when NOVA_ATTESTATION_SIGNING_KEY_ID is set.
When unset (dev/CI — D-089), signature verification is skipped + logged.
Dual-read via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.
"""
key_id = os.environ.get("ACDL_ATTESTATION_SIGNING_KEY_ID", "")
key_id = env.get_env("ATTESTATION_SIGNING_KEY_ID", "") or ""
if not key_id:
sys.stderr.write(
"[attestation] ACDL_ATTESTATION_SIGNING_KEY_ID unset — "
"[attestation] NOVA_ATTESTATION_SIGNING_KEY_ID unset — "
"signature verification skipped (dev/CI, D-089)\n"
)
return True
+14 -3
View File
@@ -36,6 +36,16 @@ import sys
import yaml
import jsonschema
# Ensure the repo root (parent of core/) is on sys.path so `from core
# import env` resolves to THIS package when contract_resolver.py is run
# as a script (python3 core/contract_resolver.py) — otherwise an
# editable-installed third-party `core` package can shadow it.
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env
def _load_env(env_name, repo_root):
"""Load the environment onboarding JSON for env_name.
@@ -620,9 +630,10 @@ if __name__ == "__main__":
idx = sys.argv.index("--environment")
if idx + 1 < len(sys.argv):
env_override = sys.argv[idx + 1]
# Also honor the ACDL_ENVIRONMENT_OVERRIDE env var (used by run_platform.sh).
if env_override is None and os.environ.get("ACDL_ENVIRONMENT_OVERRIDE"):
env_override = os.environ["ACDL_ENVIRONMENT_OVERRIDE"]
# Also honor the NOVA_ENVIRONMENT_OVERRIDE env var (used by run_platform.sh).
# Dual-read via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.
if env_override is None and env.get_env("ENVIRONMENT_OVERRIDE"):
env_override = env.get_env("ENVIRONMENT_OVERRIDE")
result = resolve(contract_path, environment_override=env_override)
with open(out_path, "w") as fh:
json.dump(result, fh, indent=2)
+48
View File
@@ -0,0 +1,48 @@
"""Dual-read environment helper (D-108, REQ-159, G-106).
During the Nova rebrand transition window (P2P4), every `NOVA_*`
environment variable is the preferred source, with the legacy `ACDL_*`
name as the fallback. This keeps deployments from breaking while the
keys are rotated across `.env`, `.env.secrets`, Gitea repo secrets, and
operator-managed process environments.
`get_env(name, default=None)` resolves `NOVA_<name>` first, then falls
back to `ACDL_<name>`, then returns `default` if neither is set.
This helper is removed (NOVA-only) in P5 (REQ-164). Direct-read paths
that bypass this helper (the `.env.secrets` shell export in
`scripts/run_platform.sh` and the Python parser in
`core/regression_verify.py`) mirror this contract inline per the G-106
binding — see those sites for the dual-read shell/Python forms.
"""
from __future__ import annotations
import os
from typing import Optional
__all__ = ["get_env"]
def get_env(name: str, default: Optional[str] = None) -> Optional[str]:
"""Resolve a config value with a NOVA-preferred / ACDL-fallback read.
`name` is the bare key WITHOUT the prefix (e.g. ``"AWS_ACCOUNT_ID"``).
The lookup order is:
1. ``NOVA_<name>`` (preferred)
2. ``ACDL_<name>`` (legacy fallback, removed in P5)
3. ``default``
Returns the first value that is present and non-empty, or ``default``
if neither env var is set. An explicitly-set empty string is treated
as "unset" so an operator cannot accidentally shadow the fallback
with a blank NOVA key.
"""
nova_val = os.environ.get(f"NOVA_{name}")
if nova_val:
return nova_val
acdl_val = os.environ.get(f"ACDL_{name}")
if acdl_val:
return acdl_val
return default
+3 -3
View File
@@ -93,7 +93,7 @@ The full table (lifted verbatim from §10.4):
The operator-supplied evidence artifact is a JSON blob with `timestamp`,
`type`, `payload`, and an optional `signature` (JWS detached). Freshness
is validated against the window above. Signature verification runs when
`ACDL_ATTESTATION_SIGNING_KEY_ID` is set; it is skipped + logged when
`NOVA_ATTESTATION_SIGNING_KEY_ID` is set; it is skipped + logged when
unset (dev/CI — D-089). The matrix fails loud if an operator-supplied
concern is missing or expired for prod/dr.
@@ -140,7 +140,7 @@ not Kyverno (in v1). Sequence:
in the same process that has authority to block the promotion.
v1.9 implements `route_halt_artifact` as a real SNS publish (topic
`acdl-sod-halt`, ARN from `ACDL_SOD_HALT_TOPIC_ARN`) with an outbox-event
`acdl-sod-halt`, ARN from `NOVA_SOD_HALT_TOPIC_ARN`) with an outbox-event
fallback when the topic ARN is unset (REQ-107). The attestation gate
itself is `core/hitl_gates.py` (`attest(contract_id, env, approver,
evidence)`), which records the approver to the outbox, runs the SoD
@@ -171,5 +171,5 @@ v1.9 (Phase 41 + Phase 42) wires the gates end-to-end:
concerns run for real; operator-supplied concerns accept signed
evidence artifacts validated for freshness + schema.
- **D-089** (v1.9) — attestation artifact signature verification is
skipped when `ACDL_ATTESTATION_SIGNING_KEY_ID` is unset (dev/CI);
skipped when `NOVA_ATTESTATION_SIGNING_KEY_ID` is unset (dev/CI);
required for prod/dr.
+16 -4
View File
@@ -12,7 +12,8 @@ evidence event) runs end-to-end against the local tier with no AWS:
Each adapter exposes the same interface as the live counterpart so the
caller code path is unchanged; only the I/O target swaps. Selection is
gated on the ACDL_LOCAL_TIER env var (set by run_platform.sh --local).
gated on the NOVA_LOCAL_TIER env var (set by run_platform.sh --local).
Dual-read via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.
"""
from __future__ import annotations
@@ -32,12 +33,20 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# Repo root on sys.path so `from core import env` resolves to THIS package
# when run as a script (avoids editable-installed third-party `core` shadow).
_REPO_ROOT = str(Path(__file__).resolve().parent.parent)
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env
ROOT = Path(__file__).resolve().parent.parent
def is_local_tier() -> bool:
"""True when the local emulating tier is active."""
return os.environ.get("ACDL_LOCAL_TIER", "") == "1"
return env.get_env("LOCAL_TIER", "") == "1"
# ---------------------------------------------------------------------------
@@ -286,7 +295,7 @@ class LocalLambdaStub:
Returns the handler's response dict
({statusCode, body}). The handler's DynamoDB calls are
intercepted via the ACDL_LOCAL_TIER env var (the handler checks
intercepted via the NOVA_LOCAL_TIER env var (the handler checks
_get_dynamodb(); under local tier it would need patching - we
patch the module's _get_dynamodb to return a local stub)."""
# Import the handler module (the dir is named `lambda`, a Python
@@ -490,6 +499,9 @@ def run_local_e2e(contract_path: str, repo_root: Optional[Path] = None) -> Dict[
if __name__ == "__main__":
contract = sys.argv[1] if len(sys.argv) > 1 else "contracts/microservice.yml"
os.environ["ACDL_LOCAL_TIER"] = "1"
# Set both so the dual-read in is_local_tier() finds NOVA_* (preferred);
# the ACDL_* alias stays for any unmigrated reader until P5.
os.environ["NOVA_LOCAL_TIER"] = "1"
os.environ["ACDL_LOCAL_TIER"] = "1" # legacy alias (dual-read fallback), removed in P5
result = run_local_e2e(contract)
print(json.dumps(result, indent=2))
+17 -7
View File
@@ -21,8 +21,16 @@ try:
except ImportError:
boto3 = None
# Repo root on sys.path so `from core import env` resolves to THIS package
# when run as a script (avoids editable-installed third-party `core` shadow).
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env as _envhelper
SSM_PREFIX = "/acdl"
KMS_KEY_ID_ENV = "ACDL_KMS_KEY_ID"
KMS_KEY_ID_ENV = "NOVA_KMS_KEY_ID"
# Outputs that are safe to display in a PR comment (no secrets).
SAFE_OUTPUT_NAMES = {
@@ -54,20 +62,22 @@ def _ssm_client():
def _kms_key_id():
"""Return the KMS key ID for SSM SecureString encryption.
P1-3: Fail loud when ACDL_KMS_KEY_ID is not set — silently falling back
P1-3: Fail loud when NOVA_KMS_KEY_ID is not set — silently falling back
to the AWS-managed key (`alias/aws/ssm`) was a security gap. The platform
CMK must be explicitly configured. Set ACDL_ALLOW_DEFAULT_KMS=1 to use
the AWS-managed key as an escape hatch for local testing.
CMK must be explicitly configured. Set NOVA_ALLOW_DEFAULT_KMS=1 to use
the AWS-managed key as an escape hatch for local testing. (Dual-read
via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.)
"""
key_id = os.environ.get(KMS_KEY_ID_ENV)
key_id = _envhelper.get_env("KMS_KEY_ID")
if key_id:
return key_id
if os.environ.get("ACDL_ALLOW_DEFAULT_KMS") == "1":
if _envhelper.get_env("ALLOW_DEFAULT_KMS") == "1":
return "alias/aws/ssm"
raise RuntimeError(
f"{KMS_KEY_ID_ENV} is not set — refusing to use the AWS-managed SSM key "
f"silently. Set {KMS_KEY_ID_ENV} to your platform CMK ARN, or set "
f"ACDL_ALLOW_DEFAULT_KMS=1 to use alias/aws/ssm (escape hatch for local testing)."
f"NOVA_ALLOW_DEFAULT_KMS=1 (ACDL_ALLOW_DEFAULT_KMS=1 fallback) to use "
f"alias/aws/ssm (escape hatch for local testing)."
)
+20 -6
View File
@@ -32,6 +32,15 @@ from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple
# Repo root on sys.path so `from core import env` resolves to THIS package
# when regression_verify.py is run as a script (avoids editable-installed
# third-party `core` shadow).
_REPO_ROOT = str(Path(__file__).resolve().parent.parent)
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env as _envhelper
ROOT = Path(__file__).resolve().parent.parent
CIAgent = ROOT / ".ciagent"
@@ -306,9 +315,13 @@ def _load_aws_env() -> Dict[str, str]:
continue
if "=" in line:
k, v = line.split("=", 1)
if k == "ACDL_AWS_ACCESS_KEY_ID":
# G-106 binding: dual-read NOVA_* first, ACDL_* fallback.
# The .env.secrets keys are renamed to NOVA_* in P2; the
# ACDL_* fallback covers operators who haven't rotated
# their local .env.secrets yet. Removed in P5.
if k == "NOVA_AWS_ACCESS_KEY_ID" or k == "ACDL_AWS_ACCESS_KEY_ID":
env["AWS_ACCESS_KEY_ID"] = v
elif k == "ACDL_AWS_SECRET_ACCESS_KEY":
elif k == "NOVA_AWS_SECRET_ACCESS_KEY" or k == "ACDL_AWS_SECRET_ACCESS_KEY":
env["AWS_SECRET_ACCESS_KEY"] = v
elif k == "AWS_DEFAULT_REGION":
env["AWS_DEFAULT_REGION"] = v
@@ -319,7 +332,8 @@ def _check_live_terraform_plan_microservice() -> Tuple[Status, str]:
"""CAP-013: terraform init+validate+plan against live AWS for the
microservice stack (D-093 live-AWS tier of the headline E2E).
Requires AWS credentials (ACDL_AWS_ACCESS_KEY_ID etc. in .env.secrets).
Requires AWS credentials (NOVA_AWS_ACCESS_KEY_ID etc. in .env.secrets;
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_")
@@ -421,7 +435,7 @@ def _check_s3_state_bucket() -> Tuple[Status, str]:
s3 = boto3.client("s3", 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"))
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
account_id = _envhelper.get_env("AWS_ACCOUNT_ID", "581513795199")
state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
s3.head_bucket(Bucket=state_bucket)
r = s3.list_objects_v2(Bucket=state_bucket, MaxKeys=5)
@@ -643,8 +657,8 @@ def write_report(report: RegressionReport,
def main() -> int:
milestone = os.environ.get("ACDL_REGRESSION_MILESTONE", "v1.10")
phase = int(os.environ.get("ACDL_REGRESSION_PHASE", "52"))
milestone = _envhelper.get_env("REGRESSION_MILESTONE", "v1.10") or "v1.10"
phase = int(_envhelper.get_env("REGRESSION_PHASE", "52") or "52")
report = run_regression(milestone=milestone, phase=phase)
md, js = write_report(report)
print(f"regression: {report.summary} -> {md}")
+15 -4
View File
@@ -5,16 +5,27 @@ Blocks on equality, emits `SEPARATION_OF_DUTIES_VIOLATION`, routes a halt
artifact to SRE on-call.
v1.9 (REQ-107, D-085): route_halt_artifact is a real implementation —
publishes to SNS topic `acdl-sod-halt` (ARN from ACDL_SOD_HALT_TOPIC_ARN)
publishes to SNS topic `acdl-sod-halt` (ARN from NOVA_SOD_HALT_TOPIC_ARN)
when set; falls back to a structured stderr emission + a
SEPARATION_OF_DUTIES_VIOLATION event write to the DynamoDB outbox when
unset. No silent print-only stub.
unset. No silent print-only stub. (Dual-read via core/env.py: NOVA_*
preferred, ACDL_* fallback until P5; the SNS topic ARN is the AWS
resource `acdl-sod-halt` → renamed `nova-sod-halt` in P4.)
"""
import os
import sys
from typing import Optional, Tuple
# Repo root on sys.path so `from core import env` resolves to THIS package
# when imported/run in a context where an editable-installed third-party
# `core` package would otherwise shadow it.
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env
def check(outbox_client, contract_id: str,
current_prod_approver: Optional[str]) -> Tuple[bool, str]:
@@ -40,13 +51,13 @@ def route_halt_artifact(contract_id: str, violation_reason: str,
oncall_client=None) -> None:
"""Route a halt artifact to SRE on-call (REQ-107, D-085).
When ACDL_SOD_HALT_TOPIC_ARN is set, publish to the SNS topic via
When NOVA_SOD_HALT_TOPIC_ARN is set, publish to the SNS topic via
boto3. When unset (dev/CI), fall back to a structured stderr emission
+ a SEPARATION_OF_DUTIES_VIOLATION event write to the DynamoDB outbox
via outbox_writer.write_event (so the halt is in the audit chain).
The oncall_client, when provided, is the SNS client (test injection).
"""
topic_arn = os.environ.get("ACDL_SOD_HALT_TOPIC_ARN", "")
topic_arn = env.get_env("SOD_HALT_TOPIC_ARN", "") or ""
halt_payload = {
"contractId": contract_id,
"reason": violation_reason,
+13 -13
View File
@@ -53,7 +53,7 @@ platform-managed. See [Environments](environments/).
## Step 1 — Create a consumer repo
Create a repository for your application. The top level holds your app
code; your contract lives at `.acdl/contract.yml`. Example for a static
code; your contract lives at `.nova/contract.yml`. Example for a static
site:
```
@@ -62,7 +62,7 @@ my-static-site/
assets/
style.css
logo.png
.acdl/
.nova/
contract.yaml
.github/
workflows/
@@ -75,7 +75,7 @@ Example for a microservice:
my-microservice/
app.py
Dockerfile
.acdl/
.nova/
contract.yaml
.github/
workflows/
@@ -83,7 +83,7 @@ my-microservice/
```
Your app code lives at the top level. Your contract lives at
`.acdl/contract.yml` regardless of the module you deploy. Your CI
`.nova/contract.yml` regardless of the module you deploy. Your CI
definition lives at `.github/workflows/deploy.yml`.
## Step 2 — Reference the central pipeline
@@ -96,7 +96,7 @@ jobs:
deploy:
uses: acdl/.github/workflows/deploy.yml@v1.13
with:
contract: .acdl/contract.yml
contract: .nova/contract.yml
environment: dev
```
@@ -106,7 +106,7 @@ field; the version pin lives in the CI workflow reference.
## Step 3 — Define the contract
Write `.acdl/contract.yml`. The `static-assets` example:
Write `.nova/contract.yml`. The `static-assets` example:
```yaml
environment: dev
@@ -179,7 +179,7 @@ jobs:
deploy:
uses: acdl/.github/workflows/deploy.yml@v1.13
with:
contract: .acdl/contract.yml
contract: .nova/contract.yml
```
That is the entire consumer-side workflow. When you push to `main`:
@@ -192,7 +192,7 @@ That is the entire consumer-side workflow. When you push to `main`:
never clone the platform repo yourself.
4. The runner installs the runtime dependencies the platform requires.
5. The runner invokes `scripts/run_platform.sh` against your
`.acdl/contract.yml`.
`.nova/contract.yml`.
You see the streamed output (infrastructure plan, policy-check results,
confidence signal) in your run logs. The `--check-only` and `--plan-only`
@@ -213,7 +213,7 @@ static key in `.env.secrets` (gitignored) is rotated **out of band by you**
locally-held copies.
```bash
bash scripts/run_platform.sh --check-only path/to/your/.acdl/contract.yml
bash scripts/run_platform.sh --check-only path/to/your/.nova/contract.yml
```
## Step 5 — What the pipeline does
@@ -355,7 +355,7 @@ destruction:
```yaml
uses: acdl/.github/workflows/deploy.yml@v1.13
with:
contract: .acdl/contract.yml
contract: .nova/contract.yml
mode: decommission
changeRequestId: "CHG0678912"
```
@@ -404,8 +404,8 @@ input). Promotion = running the matching job.
### Two shapes (both supported)
**Shape 1 — per-environment contract files:** a consumer repo has one
contract per environment (e.g. `.acdl/static-assets.dev.yml`,
`.acdl/static-assets.qa.yml`, …). Each sets `environment:` to its own
contract per environment (e.g. `.nova/static-assets.dev.yml`,
`.nova/static-assets.qa.yml`, …). Each sets `environment:` to its own
name and uses interpolation so env-specific values differ automatically:
```yaml
@@ -439,7 +439,7 @@ jobs:
uses: acdl/.github/workflows/deploy.yml@v1.13
with:
environment: qa
contract: .acdl/contract.yml
contract: .nova/contract.yml
```
### One job per environment
+1 -1
View File
@@ -7,7 +7,7 @@ deploys it.
## The contract file
A consumer repo keeps its contract at `.acdl/contract.yml`. A minimal
A consumer repo keeps its contract at `.nova/contract.yml`. A minimal
example (the `static-assets` module):
```yaml
+1 -1
View File
@@ -18,7 +18,7 @@ There are two kinds of repository in the Nova model:
and the reusable workflow files. Platform engineers work here. A consumer
never clones it.
- **Consumer repo (yours).** A consumer repo contains only its application
code, one or more contracts (`.acdl/contract.yml`), and one or more CI
code, one or more contracts (`.nova/contract.yml`), and one or more CI
definitions (a thin `.github/workflows/deploy.yml` that `uses:` the central
reusable workflow, pointing at the appropriate environment + contract).
The consumer does not write infrastructure modules, workflow YAML, or
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
deploy:
uses: acdl/.github/workflows/deploy.yml@v1.13
with:
contract: .acdl/contract.yml
contract: .nova/contract.yml
```
The version pin lives in the CI workflow reference (not in the contract
@@ -2,7 +2,7 @@
flowchart LR
A["1. App code<br/>(top level of the repo)"] --> D["Push to main"]
B["2. Contract<br/>(.acdl/contract.yml)"] --> D
B["2. Contract<br/>(.nova/contract.yml)"] --> D
C["3. CI definition<br/>(.github/workflows/deploy.yml<br/>— one 'uses:' line)"] --> D
D --> E["Platform does the rest"]
classDef accent fill:#1B1B1B,color:#fff,stroke:#D6002A,stroke-width:2px
@@ -140,7 +140,7 @@ code { font-size: 13px; }
```yaml
uses: acdl/.github/workflows/deploy.yml@v1.12
with:
contract: .acdl/contract.yml
contract: .nova/contract.yml
mode: decommission
changeRequestId: "CHG0678912"
```
@@ -456,7 +456,7 @@ img { display: block; margin: 0 auto; max-height: 280px; }
<p><img src="assets/png/developer-experience-07-decommission.png" alt="" style="width:1100px;" /></p>
<pre is="marp-pre" data-auto-scaling="downscale-only"><code class="language-yaml"><span class="hljs-attr">uses:</span> <span class="hljs-string">acdl/.github/workflows/deploy.yml@v1.12</span>
<span class="hljs-attr">with:</span>
<span class="hljs-attr">contract:</span> <span class="hljs-string">.acdl/contract.yml</span>
<span class="hljs-attr">contract:</span> <span class="hljs-string">.nova/contract.yml</span>
<span class="hljs-attr">mode:</span> <span class="hljs-string">decommission</span>
<span class="hljs-attr">changeRequestId:</span> <span class="hljs-string">&quot;CHG0678912&quot;</span>
</code></pre>
@@ -233,7 +233,7 @@ flowchart LR
```yaml
uses: acdl/.github/workflows/deploy.yml@v1.12
with:
contract: .acdl/contract.yml
contract: .nova/contract.yml
mode: decommission
changeRequestId: "CHG0678912"
```
+1 -1
View File
@@ -13,7 +13,7 @@ Nova uses JSON Schema draft 2020-12 for all declarative contracts. Schemas are t
| Nova Central Pipeline Contract | `pipeline.schema.json` | Central CI pipeline contract (stages, commands, triggers, runner) | `tests/test_pipeline_contract.py` |
| Nova Central Deployment Pipeline Contract | `deploy-pipeline.schema.json` | Central deploy pipeline contract (validate → resolve → plan → checkov → confidence → apply → publish → uptime → comment) | `tests/test_pipeline_contract.py` |
| Nova PolicyCheckResult | `policy_check_result.schema.json` | Normalized policy check result schema (the contract between policy engines and the confidence signal) | `tests/conftest.py`, all adapter tests |
| Nova Tagging Standard | `tagging-standard.json` | Required tag set for all taggable AWS resources | `adapters/terraform/policy/custom_rules/acdl_tagging.py` |
| Nova Tagging Standard | `tagging-standard.json` | Required tag set for all taggable AWS resources | `adapters/terraform/policy/custom_rules/nova_tagging.py` |
## How to Write a Schema
+1 -1
View File
@@ -24,7 +24,7 @@
},
"ruleId": {
"type": "string",
"description": "Rule identifier (e.g. CKV_AWS_24, KYVERNO_NO_PRIVILEGED, ACDL_TAG_NAMING)."
"description": "Rule identifier (e.g. CKV_AWS_24, KYVERNO_NO_PRIVILEGED, NOVA_TAG_NAMING)."
},
"severity": {
"type": "string",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://nova.dev/schemas/tagging-standard.json",
"title": "Nova Tagging Standard",
"description": "Required tags for all taggable AWS resources created by the platform. Enforced by a Checkov custom Python rule (adapters/terraform/policy/custom_rules/acdl_tagging.py). The checkov adapter maps ACDL_TAG_NAMING as a real rule (D-054, D-043 closure).",
"description": "Required tags for all taggable AWS resources created by the platform. Enforced by a Checkov custom Python rule (adapters/terraform/policy/custom_rules/nova_tagging.py, D-109 warn mode in P2 — legacy acdl:* tag-key values are left for P3). The checkov adapter maps NOVA_TAG_NAMING as a real rule (D-054, D-043 closure; renamed from ACDL_TAG_NAMING in P2, REQ-158).",
"type": "object",
"properties": {
"required_tags": {
+17 -6
View File
@@ -3,7 +3,8 @@
Steps performed by this script:
1. Load AWS creds from /root/acdl/.env.secrets
(ACDL_AWS_ACCESS_KEY_ID, ACDL_AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION).
(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
(ecr:DescribeRepositories / ecr:CreateRepository). Region: us-east-1.
3. Get the ECR login password (ecr:GetAuthorizationToken) and run
@@ -26,17 +27,26 @@ import pathlib
import boto3
# Repo root on sys.path so `from core import env` resolves to THIS package
# (avoids editable-installed third-party `core` shadow).
_REPO_ROOT = str(pathlib.Path(__file__).resolve().parent.parent)
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
ENV_FILE = REPO_ROOT / ".env.secrets"
AWS_ACCOUNT_ID = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
AWS_ACCOUNT_ID = env.get_env("AWS_ACCOUNT_ID", "581513795199")
AWS_REGION = "us-east-1"
ECR_REPO_NAME = "acdl-microservice"
IMAGE_TAG = "latest"
def _load_env(path):
"""Load ACDL_AWS_* + AWS_DEFAULT_REGION from a flat KEY=VALUE file."""
"""Load NOVA_AWS_* (preferred) / ACDL_AWS_* (fallback) + AWS_DEFAULT_REGION
from a flat KEY=VALUE file (dual-read per G-106, until P5)."""
creds = {}
with open(path, "r") as fh:
for line in fh:
@@ -54,11 +64,12 @@ def main():
return 2
creds = _load_env(ENV_FILE)
access_key = creds.get("ACDL_AWS_ACCESS_KEY_ID")
secret_key = creds.get("ACDL_AWS_SECRET_ACCESS_KEY")
# Dual-read: NOVA_* preferred, ACDL_* fallback (G-106, removed in P5).
access_key = creds.get("NOVA_AWS_ACCESS_KEY_ID") or creds.get("ACDL_AWS_ACCESS_KEY_ID")
secret_key = creds.get("NOVA_AWS_SECRET_ACCESS_KEY") or creds.get("ACDL_AWS_SECRET_ACCESS_KEY")
region = creds.get("AWS_DEFAULT_REGION", AWS_REGION)
if not access_key or not secret_key:
print("FAIL: ACDL_AWS_ACCESS_KEY_ID / ACDL_AWS_SECRET_ACCESS_KEY missing",
print("FAIL: NOVA_AWS_ACCESS_KEY_ID / NOVA_AWS_SECRET_ACCESS_KEY missing",
file=sys.stderr)
return 2
+24 -12
View File
@@ -1,18 +1,22 @@
#!/usr/bin/env bash
# scripts/rotate_spike_key.sh - rotate the acdl-spike-runner IAM access key.
#
# Uses the bootstrap root key (ACDL_BOOTSTRAP_AWS_*) from the env to:
# Uses the bootstrap root key (NOVA_BOOTSTRAP_AWS_*, ACDL_BOOTSTRAP_AWS_*
# fallback) from the env to:
# 1. List acdl-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).
# 5. Optionally upload to Gitea secrets if ACDL_GITEA_TOKEN is set.
# 5. Optionally upload to Gitea secrets if NOVA_GITEA_TOKEN is set.
#
# Idempotent: re-running always ends with exactly 1 active key for the user.
# Does NOT rotate the bootstrap root key (D-034 closure = manual user step).
#
# 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).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
@@ -20,8 +24,9 @@ ENV_FILE="$ROOT/.env.secrets"
fail() { echo "FAIL: $*" >&2; exit 1; }
: "${ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID:?set ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID to the root key}"
: "${ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY:?set ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY to the root key}"
# Dual-read bootstrap creds: NOVA_* preferred, ACDL_* fallback (removed in P5).
: "${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"
@@ -38,9 +43,13 @@ region = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
user = "acdl-spike-runner"
env_file = os.path.join(os.getcwd(), ".env.secrets")
# Dual-read bootstrap creds: NOVA_* preferred, ACDL_* fallback (G-106, removed in P5).
bootstrap_key = os.environ.get("NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID") or os.environ["ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID"]
bootstrap_secret = os.environ.get("NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY") or os.environ["ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY"]
session = boto3.Session(
aws_access_key_id=os.environ["ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID"],
aws_secret_access_key=os.environ["ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY"],
aws_access_key_id=bootstrap_key,
aws_secret_access_key=bootstrap_secret,
region_name=region,
)
iam = session.client("iam")
@@ -65,20 +74,23 @@ for k in active:
print(f"iam: deactivated+deleted old key {old_id}", file=sys.stderr)
# Write the new key to gitignored .env.secrets (chmod 600).
# Nova rebrand (P2): keys are NOVA_*; the ACDL_* legacy keys are the
# dual-read fallback source until P5 (kept as comments in .env.secrets).
with open(env_file, "w") as fh:
fh.write(f"ACDL_AWS_ACCESS_KEY_ID={new_id}\n")
fh.write(f"ACDL_AWS_SECRET_ACCESS_KEY={new_secret}\n")
fh.write(f"NOVA_AWS_ACCESS_KEY_ID={new_id}\n")
fh.write(f"NOVA_AWS_SECRET_ACCESS_KEY={new_secret}\n")
fh.write(f"AWS_DEFAULT_REGION={region}\n")
os.chmod(env_file, 0o600)
print(f"rotated key written to {env_file} (chmod 600)", file=sys.stderr)
# Optionally upload to Gitea secrets.
gitea_token = os.environ.get("ACDL_GITEA_TOKEN")
# Dual-read token: NOVA_GITEA_TOKEN preferred, ACDL_GITEA_TOKEN fallback (G-106).
gitea_token = os.environ.get("NOVA_GITEA_TOKEN") or os.environ.get("ACDL_GITEA_TOKEN")
if gitea_token:
import urllib.request
base = "https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/acdl/actions/secrets"
for name, value in [("ACDL_AWS_ACCESS_KEY_ID", new_id),
("ACDL_AWS_SECRET_ACCESS_KEY", new_secret)]:
for name, value in [("NOVA_AWS_ACCESS_KEY_ID", new_id),
("NOVA_AWS_SECRET_ACCESS_KEY", new_secret)]:
req = urllib.request.Request(
f"{base}/{name}",
data=json.dumps({"value": value}).encode(),
@@ -92,7 +104,7 @@ if gitea_token:
except Exception as e:
print(f"gitea: secret {name} upload FAILED: {e}", file=sys.stderr)
else:
print("gitea: ACDL_GITEA_TOKEN not set; Gitea secret upload skipped (v1.2 hardening)", file=sys.stderr)
print("gitea: NOVA_GITEA_TOKEN not set; Gitea secret upload skipped (v1.2 hardening)", file=sys.stderr)
print(f"OK: {user} now has exactly 1 active key: {new_id}")
PY
+2 -1
View File
@@ -45,11 +45,12 @@ python3 -m py_compile \
core/confidence_signal.py \
core/outbox_writer.py \
core/output_publisher.py \
core/env.py \
core/contract_resolver.py \
core/lambda/contract_ingestor.py \
adapters/terraform/adapter.py \
adapters/terraform/policy/checkov_adapter.py \
adapters/terraform/policy/custom_rules/acdl_tagging.py \
adapters/terraform/policy/custom_rules/nova_tagging.py \
adapters/wiz/wiz_adapter.py \
adapters/kyverno/kyverno_adapter.py \
scripts/push_consumer_image.py \
+10 -5
View File
@@ -4,7 +4,8 @@
# Usage: run_l2_lifecycle_destroy.sh <module>
#
# Wraps run_platform.sh for L2 composition modules in the modules-lifecycle
# pipeline. Sets ACDL_REMOTE_STATE_KEY to point to the CI VPC state.
# pipeline. Sets NOVA_REMOTE_STATE_KEY (ACDL_REMOTE_STATE_KEY fallback until
# P5) to point to the CI VPC state.
#
# NOTE: unlike the L1 scripts (run_lifecycle_destroy.sh), the L2 path does
# NOT take a ci-vpc-outputs.json argument. L2 compositions reference the
@@ -13,7 +14,8 @@
# parity with the L1 matrix, but $2 is accepted-but-ignored here (documented,
# not a bug).
#
# Lifecycle mode (REQ-134): ACDL_LIFECYCLE_MODE default "plan" = no-op
# Lifecycle mode (REQ-134): NOVA_LIFECYCLE_MODE (dual-read NOVA_* preferred,
# ACDL_* fallback until P5) default "plan" = no-op
# (plan mode never applies resources, so there is nothing to destroy).
# Set to "full" for the real `--destroy` against live AWS.
set -euo pipefail
@@ -23,7 +25,8 @@ cd "$ROOT"
MODULE="$1"
# Lifecycle mode: "plan" (default) skips destroy; "full" runs the real destroy.
LIFECYCLE_MODE="${ACDL_LIFECYCLE_MODE:-plan}"
# Dual-read: NOVA_* preferred, ACDL_* fallback (removed in P5).
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-${ACDL_LIFECYCLE_MODE:-plan}}"
if [ "$LIFECYCLE_MODE" != "full" ]; then
echo "lifecycle mode=$LIFECYCLE_MODE — nothing to destroy (plan-only run), exiting 0"
@@ -32,8 +35,10 @@ fi
CONTRACT="modules/l2/${MODULE}/examples/complex.yml"
# Point terraform_remote_state to the CI VPC state (not the platform VPC)
export ACDL_REMOTE_STATE_KEY="spike/ci-vpc/terraform.tfstate"
# Point terraform_remote_state to the CI VPC state (not the platform VPC).
# Set both NOVA_* (preferred) and ACDL_* (legacy fallback) until P5.
export NOVA_REMOTE_STATE_KEY="spike/ci-vpc/terraform.tfstate"
export ACDL_REMOTE_STATE_KEY="spike/ci-vpc/terraform.tfstate" # legacy fallback, removed in P5
# Run the platform lifecycle destroy command
bash scripts/run_platform.sh --destroy "$CONTRACT"
+14 -8
View File
@@ -4,19 +4,21 @@
# Usage: run_l2_lifecycle_test.sh <module> <example>
#
# Wraps run_platform.sh for L2 composition modules in the modules-lifecycle
# pipeline. Sets ACDL_REMOTE_STATE_KEY to point to the CI VPC state so the
# microservice composition's terraform_remote_state data source reads from
# the short-lived CI VPC (not the long-lived platform VPC).
# pipeline. Sets NOVA_REMOTE_STATE_KEY (ACDL_REMOTE_STATE_KEY fallback until
# P5) to point to the CI VPC state so the microservice composition's
# terraform_remote_state data source reads from the short-lived CI VPC
# (not the long-lived platform VPC).
#
# NOTE: unlike the L1 scripts (run_lifecycle_test.sh), the L2 path does NOT
# take a ci-vpc-outputs.json argument. L2 compositions reference the platform
# VPC via terraform_remote_state (a data source), not by injecting VPC
# outputs into the contract. The ACDL_REMOTE_STATE_KEY env var points the
# outputs into the contract. The NOVA_REMOTE_STATE_KEY env var points the
# data source at the correct CI VPC state key. The workflow passes 3
# positional args for parity with the L1 matrix, but $3 is accepted-but-
# ignored here (documented, not a bug).
#
# Lifecycle mode (REQ-134): ACDL_LIFECYCLE_MODE default "plan" runs
# Lifecycle mode (REQ-134): NOVA_LIFECYCLE_MODE (dual-read NOVA_* preferred,
# ACDL_* fallback until P5) default "plan" runs
# `run_platform.sh --plan-only` (fast, no AWS mutation). Set to "full" for
# the real `--apply` against live AWS.
set -euo pipefail
@@ -27,12 +29,16 @@ MODULE="$1"
EXAMPLE="$2" # simple or complex
# Lifecycle mode: "plan" (default, fast) or "full" (real apply against AWS).
LIFECYCLE_MODE="${ACDL_LIFECYCLE_MODE:-plan}"
# Dual-read: NOVA_* preferred, ACDL_* fallback (removed in P5).
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-${ACDL_LIFECYCLE_MODE:-plan}}"
CONTRACT="modules/l2/${MODULE}/examples/${EXAMPLE}.yml"
# Point terraform_remote_state to the CI VPC state (not the platform VPC)
export ACDL_REMOTE_STATE_KEY="spike/ci-vpc/terraform.tfstate"
# Point terraform_remote_state to the CI VPC state (not the platform VPC).
# Set both NOVA_* (preferred by the dual-read helper) and ACDL_* (legacy
# fallback) so any unmigrated reader finds the key until P5.
export NOVA_REMOTE_STATE_KEY="spike/ci-vpc/terraform.tfstate"
export ACDL_REMOTE_STATE_KEY="spike/ci-vpc/terraform.tfstate" # legacy fallback, removed in P5
# Run the platform lifecycle command (plan-only by default; full = apply).
if [ "$LIFECYCLE_MODE" = "full" ]; then
+4 -2
View File
@@ -6,7 +6,8 @@
# For VPC-dependent modules, injects CI VPC outputs into the complex contract
# before destroy (so terraform can find the resources in the right VPC).
#
# Lifecycle mode (REQ-134): ACDL_LIFECYCLE_MODE default "plan" = no-op
# Lifecycle mode (REQ-134): NOVA_LIFECYCLE_MODE (dual-read NOVA_* preferred,
# ACDL_* fallback until P5) default "plan" = no-op
# (plan mode never applies resources, so there is nothing to destroy; the
# script exits 0 so the pipeline matrix cell stays green). Set to "full"
# for the real `--destroy` against live AWS.
@@ -19,7 +20,8 @@ CI_VPC_OUTPUTS="${2:-}"
# Lifecycle mode: "plan" (default) skips destroy (nothing was applied);
# "full" runs the real terraform destroy.
LIFECYCLE_MODE="${ACDL_LIFECYCLE_MODE:-plan}"
# Dual-read: NOVA_* preferred, ACDL_* fallback (removed in P5).
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-${ACDL_LIFECYCLE_MODE:-plan}}"
if [ "$LIFECYCLE_MODE" != "full" ]; then
echo "lifecycle mode=$LIFECYCLE_MODE — nothing to destroy (plan-only run), exiting 0"
+5 -3
View File
@@ -10,8 +10,9 @@
# The CI VPC is short-lived (created/destroyed by the pipeline), separate
# from the long-lived platform VPC.
#
# Lifecycle mode (REQ-134): the ACDL_LIFECYCLE_MODE env var selects the
# tier. Default "plan" runs `run_platform.sh --plan-only` (fast, no AWS
# Lifecycle mode (REQ-134): the NOVA_LIFECYCLE_MODE env var selects the
# tier (dual-read NOVA_* preferred, ACDL_* fallback until P5). Default
# "plan" runs `run_platform.sh --plan-only` (fast, no AWS
# mutation, validates the contract->resolver->adapter->plan chain for
# every module). Set to "full" to run the real `--apply` (terraform apply
# against live AWS). The CI variable is passed via the workflow input
@@ -25,7 +26,8 @@ EXAMPLE="$2" # simple or complex
CI_VPC_OUTPUTS="${3:-}"
# Lifecycle mode: "plan" (default, fast) or "full" (real apply against AWS).
LIFECYCLE_MODE="${ACDL_LIFECYCLE_MODE:-plan}"
# Dual-read: NOVA_* preferred, ACDL_* fallback (removed in P5).
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-${ACDL_LIFECYCLE_MODE:-plan}}"
CONTRACT="modules/l1/${MODULE}/examples/${EXAMPLE}.yml"
+22 -14
View File
@@ -40,7 +40,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Capture the caller's CWD before we cd to ROOT. The reusable deploy workflow
# invokes this script from the CONSUMER repo's workspace root with a relative
# contract path (e.g. .acdl/contract.yml); the contract must resolve against
# contract path (e.g. .nova/contract.yml); the contract must resolve against
# the consumer repo, not the platform repo (platform/). Without this, the
# `[ -f "$CONTRACT" ]` check below looks for the contract inside the platform
# repo and fails (P0 fix — see docs/CONSUMER_GUIDE.md Step 4).
@@ -118,10 +118,12 @@ fail() { echo "FAIL: $*" >&2; exit 1; }
# state, and the contract-ingestor Lambda in-process. Exits 0 on success.
if [ "$LOCAL_TIER" = "1" ]; then
[ -n "$CONTRACT" ] || CONTRACT="contracts/microservice.yml"
echo "=== ACDL Local Emulating Tier (D-092) ==="
echo "=== Nova Local Emulating Tier (D-092) ==="
echo "contract: $CONTRACT (no AWS credentials required)"
echo ""
ACDL_LOCAL_TIER=1 python3 core/local_emulators.py "$CONTRACT" \
# Dual-read: set NOVA_LOCAL_TIER (preferred); ACDL_LOCAL_TIER fallback
# kept for any unmigrated reader until P5 (removed in P5).
NOVA_LOCAL_TIER=1 ACDL_LOCAL_TIER=1 python3 core/local_emulators.py "$CONTRACT" \
|| fail "local E2E failed"
echo ""
echo "=== LOCAL E2E OK ==="
@@ -147,7 +149,8 @@ rm -rf "$WORK"; mkdir -p "$TF_DIR"
echo "=== Step 0: environment onboarding check ==="
if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
export ACDL_ENVIRONMENT_OVERRIDE="$ENVIRONMENT_OVERRIDE"
export NOVA_ENVIRONMENT_OVERRIDE="$ENVIRONMENT_OVERRIDE"
export ACDL_ENVIRONMENT_OVERRIDE="$ENVIRONMENT_OVERRIDE" # legacy fallback, removed in P5
python3 core/environment_check.py --env="$ENVIRONMENT_OVERRIDE" || {
echo "FAIL: environment not bound — see the onboarding prompt above" >&2
exit 1
@@ -285,8 +288,11 @@ if [ -z "${AWS_ACCESS_KEY_ID:-}" ] || [ -z "${AWS_SECRET_ACCESS_KEY:-}" ]; then
set -a
. "$ENV_FILE"
set +a
export AWS_ACCESS_KEY_ID="$ACDL_AWS_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$ACDL_AWS_SECRET_ACCESS_KEY"
# G-106 binding: dual-read NOVA_* first, ACDL_* fallback. The .env.secrets
# keys are renamed to NOVA_* in P2; the ACDL_* fallback covers operators
# who haven't rotated their local .env.secrets yet. Removed in P5.
export AWS_ACCESS_KEY_ID="${NOVA_AWS_ACCESS_KEY_ID:-$ACDL_AWS_ACCESS_KEY_ID}"
export AWS_SECRET_ACCESS_KEY="${NOVA_AWS_SECRET_ACCESS_KEY:-$ACDL_AWS_SECRET_ACCESS_KEY}"
export AWS_DEFAULT_REGION="$AWS_DEFAULT_REGION"
fi
@@ -333,16 +339,17 @@ if [ "$APPLY_ONLY" = "1" ]; then
import os, sys
sys.path.insert(0, '.')
from core.hitl_gates import attest
contract_id = os.environ['ACDL_HITL_CONTRACT_ID']
env = os.environ['ACDL_HITL_ENV']
approver = os.environ.get('ACDL_HITL_APPROVER', '') or 'local-test'
from core import env as _envhelper
contract_id = _envhelper.get_env('HITL_CONTRACT_ID') or os.environ['NOVA_HITL_CONTRACT_ID']
env = _envhelper.get_env('HITL_ENV') or os.environ['NOVA_HITL_ENV']
approver = _envhelper.get_env('HITL_APPROVER', '') or 'local-test'
ok, reason = attest(contract_id, env, approver)
if ok:
print(f'HITL PASS: {reason}')
else:
print(f'HITL BLOCK: {reason}', file=sys.stderr)
sys.exit(1)
" ACDL_HITL_CONTRACT_ID="$CONTRACT_ID" ACDL_HITL_ENV="$RESOLVED_ENV" ACDL_HITL_APPROVER="$APPROVER" || { echo "FAIL: HITL attestation gate blocked the apply" >&2; exit 1; }
" NOVA_HITL_CONTRACT_ID="$CONTRACT_ID" NOVA_HITL_ENV="$RESOLVED_ENV" NOVA_HITL_APPROVER="$APPROVER" ACDL_HITL_CONTRACT_ID="$CONTRACT_ID" ACDL_HITL_ENV="$RESOLVED_ENV" ACDL_HITL_APPROVER="$APPROVER" || { echo "FAIL: HITL attestation gate blocked the apply" >&2; exit 1; }
else
echo "Environment is dev — autonomous (no HITL gate)."
fi
@@ -448,16 +455,17 @@ if [ "$RESOLVED_ENV" != "dev" ]; then
import os, sys
sys.path.insert(0, '.')
from core.hitl_gates import attest
contract_id = os.environ['ACDL_HITL_CONTRACT_ID']
env = os.environ['ACDL_HITL_ENV']
approver = os.environ.get('ACDL_HITL_APPROVER', '') or 'local-test'
from core import env as _envhelper
contract_id = _envhelper.get_env('HITL_CONTRACT_ID') or os.environ['NOVA_HITL_CONTRACT_ID']
env = _envhelper.get_env('HITL_ENV') or os.environ['NOVA_HITL_ENV']
approver = _envhelper.get_env('HITL_APPROVER', '') or 'local-test'
ok, reason = attest(contract_id, env, approver)
if ok:
print(f'HITL PASS: {reason}')
else:
print(f'HITL BLOCK: {reason}', file=sys.stderr)
sys.exit(1)
" ACDL_HITL_CONTRACT_ID="$CONTRACT_ID" ACDL_HITL_ENV="$RESOLVED_ENV" ACDL_HITL_APPROVER="$APPROVER" || { echo "FAIL: HITL attestation gate blocked the promotion" >&2; exit 1; }
" NOVA_HITL_CONTRACT_ID="$CONTRACT_ID" NOVA_HITL_ENV="$RESOLVED_ENV" NOVA_HITL_APPROVER="$APPROVER" ACDL_HITL_CONTRACT_ID="$CONTRACT_ID" ACDL_HITL_ENV="$RESOLVED_ENV" ACDL_HITL_APPROVER="$APPROVER" || { echo "FAIL: HITL attestation gate blocked the promotion" >&2; exit 1; }
else
echo "Environment is dev — autonomous (no HITL gate)."
fi
+5 -3
View File
@@ -7,8 +7,9 @@
#
# Usage:
# bash scripts/run_regression.sh # run all checks
# ACDL_REGRESSION_MILESTONE=v1.10 ACDL_REGRESSION_PHASE=52 \
# NOVA_REGRESSION_MILESTONE=v1.10 NOVA_REGRESSION_PHASE=52 \
# bash scripts/run_regression.sh # override metadata
# (ACDL_REGRESSION_* legacy fallback kept until P5)
#
# Output:
# .ciagent/REGRESSION_REPORT.md human-readable report
@@ -17,8 +18,9 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
echo "=== ACDL Regression VERIFY (D-091) ==="
echo "milestone: ${ACDL_REGRESSION_MILESTONE:-v1.10} phase: ${ACDL_REGRESSION_PHASE:-52}"
echo "=== Nova Regression VERIFY (D-091) ==="
# Dual-read: NOVA_* preferred, ACDL_* fallback (removed in P5).
echo "milestone: ${NOVA_REGRESSION_MILESTONE:-${ACDL_REGRESSION_MILESTONE:-v1.10}} phase: ${NOVA_REGRESSION_PHASE:-${ACDL_REGRESSION_PHASE:-52}}"
echo ""
python3 core/regression_verify.py
+7 -5
View File
@@ -408,14 +408,16 @@ class TestAdapterDedupMergesSameModule:
class TestAdapterRemoteStateKeyOverride:
"""P2-2 (v1.14, REQ-139): ACDL_REMOTE_STATE_KEY env var overrides the
default 'platform/terraform.tfstate' key in the emitted
"""P2-2 (v1.14, REQ-139): NOVA_REMOTE_STATE_KEY env var (P2 renamed from
ACDL_REMOTE_STATE_KEY; dual-read NOVA_* preferred, ACDL_* fallback until
P5) overrides the default 'platform/terraform.tfstate' key in the emitted
data terraform_remote_state block. This is the load-bearing correctness
mechanism for the microservice L2 lifecycle (remote state points at the
CI VPC, not the platform VPC)."""
def test_default_remote_state_key(self, tmp_path, monkeypatch):
"""When ACDL_REMOTE_STATE_KEY is unset, the default key is used."""
"""When NOVA_REMOTE_STATE_KEY is unset, the default key is used."""
monkeypatch.delenv("NOVA_REMOTE_STATE_KEY", raising=False)
monkeypatch.delenv("ACDL_REMOTE_STATE_KEY", raising=False)
stack = {
"resources": [
@@ -431,9 +433,9 @@ class TestAdapterRemoteStateKeyOverride:
assert "platform/terraform.tfstate" in main_tf
def test_env_override_remote_state_key(self, tmp_path, monkeypatch):
"""When ACDL_REMOTE_STATE_KEY is set, the emitted data block uses
"""When NOVA_REMOTE_STATE_KEY is set, the emitted data block uses
the overridden key (e.g. 'spike/ci-vpc/terraform.tfstate')."""
monkeypatch.setenv("ACDL_REMOTE_STATE_KEY", "spike/ci-vpc/terraform.tfstate")
monkeypatch.setenv("NOVA_REMOTE_STATE_KEY", "spike/ci-vpc/terraform.tfstate")
stack = {
"resources": [
{"id": "s3", "type": "aws:s3:bucket", "module": "s3@1.0.0", "inputs": {"bucket_name": "test", "region": "us-east-1"}}
+3 -1
View File
@@ -102,6 +102,8 @@ def test_dr_blocks_on_missing_dr_drill():
def test_signature_skip_when_key_unset(monkeypatch, capsys):
"""D-089: signature verification is skipped when the signing key is unset."""
# P2: dual-read — both NOVA_* and ACDL_* must be unset for the skip.
monkeypatch.delenv("NOVA_ATTESTATION_SIGNING_KEY_ID", raising=False)
monkeypatch.delenv("ACDL_ATTESTATION_SIGNING_KEY_ID", raising=False)
artifact = {"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"type": "x", "payload": {}, "signature": "sig"}
@@ -112,7 +114,7 @@ def test_signature_skip_when_key_unset(monkeypatch, capsys):
def test_signature_required_when_key_set(monkeypatch):
"""When the signing key is set, a missing signature fails."""
monkeypatch.setenv("ACDL_ATTESTATION_SIGNING_KEY_ID", "kms-key-id")
monkeypatch.setenv("NOVA_ATTESTATION_SIGNING_KEY_ID", "kms-key-id")
artifact = {"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"type": "x", "payload": {}} # no signature
assert _verify_signature(artifact) is False
+6 -6
View File
@@ -67,10 +67,10 @@ class TestToPcr:
class TestRuleMapTagging:
def test_acdl_tag_naming_is_real_rule(self):
# D-054 / D-043 closure: ACDL_TAG_NAMING is now a real custom Checkov
# rule, not a synthetic SKIPPED record.
assert RULE_MAP["ACDL_TAG_NAMING"] == ("tagging-standard", "medium")
def test_nova_tag_naming_is_real_rule(self):
# D-054 / D-043 closure, P2 rename (REQ-158): NOVA_TAG_NAMING is now
# a real custom Checkov rule, not a synthetic SKIPPED record.
assert RULE_MAP["NOVA_TAG_NAMING"] == ("tagging-standard", "medium")
class TestAdapt:
@@ -100,13 +100,13 @@ class TestAdapt:
def test_adapt_does_not_emit_synthetic_tag_naming(self, tmp_path):
# D-043 closure: adapt() no longer appends a synthetic SKIPPED
# ACDL_TAG_NAMING record. The custom Checkov rule (loaded via
# NOVA_TAG_NAMING record. The custom Checkov rule (loaded via
# --external-checks-dir) produces real PASS/FAIL records instead.
data = self._sample_checkov_json()
f = tmp_path / "checkov.json"
f.write_text(json.dumps(data))
results = adapt(str(f), "c-1")
tag = [r for r in results if r["ruleId"] == "ACDL_TAG_NAMING"]
tag = [r for r in results if r["ruleId"] == "NOVA_TAG_NAMING"]
assert tag == [] # no synthetic record
def test_adapt_has_passed_and_failed(self, tmp_path):
+3 -1
View File
@@ -78,4 +78,6 @@ def test_run_platform_sh_has_environment_flag():
text = (ROOT / "scripts" / "run_platform.sh").read_text()
assert "--environment" in text
assert "ENVIRONMENT_OVERRIDE" in text
assert "ACDL_ENVIRONMENT_OVERRIDE" in text
# P2 (REQ-159): NOVA_* preferred; ACDL_* kept as dual-read fallback until P5.
assert "NOVA_ENVIRONMENT_OVERRIDE" in text
assert "ACDL_ENVIRONMENT_OVERRIDE" in text # legacy fallback, removed in P5
+56
View File
@@ -0,0 +1,56 @@
"""Unit tests for the dual-read env helper (core/env.py, D-108, REQ-159).
Covers the four cases:
- both NOVA_* and ACDL_* set (NOVA wins)
- only NOVA_* set
- only ACDL_* set (fallback)
- neither set (default returned)
The ACDL_* fallback is the intentional dual-read source and is removed
in P5 (REQ-164). These fixtures deliberately keep the ACDL_* names as
the fallback source they are the one allowed ACDL_* reference.
"""
from __future__ import annotations
import pytest
from core import env
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch):
"""Ensure no ACDL_*/NOVA_* leakage between tests."""
for key in list(__import__("os").environ):
if key.startswith(("ACDL_", "NOVA_")):
monkeypatch.delenv(key, raising=False)
yield
def test_both_set_nova_wins(monkeypatch):
monkeypatch.setenv("NOVA_AWS_ACCOUNT_ID", "nova-value")
monkeypatch.setenv("ACDL_AWS_ACCOUNT_ID", "acdl-value")
assert env.get_env("AWS_ACCOUNT_ID") == "nova-value"
def test_only_nova_set(monkeypatch):
monkeypatch.setenv("NOVA_AWS_ACCOUNT_ID", "nova-value")
assert env.get_env("AWS_ACCOUNT_ID") == "nova-value"
def test_only_acdl_set_fallback(monkeypatch):
# ACDL_* is the intentional dual-read fallback source (removed in P5).
monkeypatch.setenv("ACDL_AWS_ACCOUNT_ID", "acdl-value")
assert env.get_env("AWS_ACCOUNT_ID") == "acdl-value"
def test_neither_set_returns_default():
assert env.get_env("AWS_ACCOUNT_ID") is None
assert env.get_env("AWS_ACCOUNT_ID", default="581513795199") == "581513795199"
def test_blank_nova_falls_back_to_acdl(monkeypatch):
# An explicitly-empty NOVA key must not shadow the ACDL fallback.
monkeypatch.setenv("NOVA_AWS_ACCOUNT_ID", "")
monkeypatch.setenv("ACDL_AWS_ACCOUNT_ID", "acdl-value")
assert env.get_env("AWS_ACCOUNT_ID") == "acdl-value"
+11 -8
View File
@@ -2,9 +2,10 @@
The lifecycle scripts (run_lifecycle_test.sh, run_lifecycle_destroy.sh,
run_l2_lifecycle_test.sh, run_l2_lifecycle_destroy.sh) wrap run_platform.sh.
REQ-134 (v1.12) adds the ACDL_LIFECYCLE_MODE env var: default "plan" runs
`run_platform.sh --plan-only` (fast, no AWS mutation); "full" runs the real
`--apply`/`--destroy` against live AWS.
REQ-134 (v1.12) adds the NOVA_LIFECYCLE_MODE env var (P2 renamed from
ACDL_LIFECYCLE_MODE, dual-read NOVA_* preferred / ACDL_* fallback until
P5): default "plan" runs `run_platform.sh --plan-only` (fast, no AWS
mutation); "full" runs the real `--apply`/`--destroy` against live AWS.
These tests verify the dispatch logic offline by inspecting script content
(running the scripts end-to-end requires AWS credentials in full mode).
@@ -32,12 +33,14 @@ class TestLifecycleModeFlag:
@pytest.mark.parametrize("script", SCRIPTS)
def test_script_reads_acdl_lifecycle_mode(self, script):
"""Every lifecycle script reads ACDL_LIFECYCLE_MODE with a 'plan' default."""
"""Every lifecycle script reads NOVA_LIFECYCLE_MODE (dual-read with
ACDL_LIFECYCLE_MODE fallback) with a 'plan' default."""
src = _read(script)
# The default must be 'plan' (the speed-up default). The scripts use
# an intermediate LIFECYCLE_MODE var sourced from ACDL_LIFECYCLE_MODE.
assert "${ACDL_LIFECYCLE_MODE:-plan}" in src, \
f"{script} must read ACDL_LIFECYCLE_MODE defaulting to 'plan'"
# P2 (REQ-159): dual-read NOVA_* preferred, ACDL_* fallback. The
# ACDL_LIFECYCLE_MODE:-plan substring is still present inside the
# nested dual-read expression (removed in P5).
assert "${NOVA_LIFECYCLE_MODE:-${ACDL_LIFECYCLE_MODE:-plan}}" in src, \
f"{script} must dual-read NOVA_/ACDL_LIFECYCLE_MODE defaulting to 'plan'"
assert "LIFECYCLE_MODE=" in src, \
f"{script} must assign LIFECYCLE_MODE from the env var"
+6 -6
View File
@@ -179,11 +179,11 @@ def test_local_lambda_stub_rejects_missing_field(tmp_path):
def test_run_local_e2e_microservice():
"""Headline E2E: contract -> resolver -> adapter -> local S3 backend
-> local ECS (HTTP 200) -> flat-file outbox -> local Lambda. No AWS."""
os.environ["ACDL_LOCAL_TIER"] = "1"
os.environ["NOVA_LOCAL_TIER"] = "1"
try:
result = le.run_local_e2e("contracts/microservice.yml")
finally:
os.environ.pop("ACDL_LOCAL_TIER", None)
os.environ.pop("NOVA_LOCAL_TIER", None); os.environ.pop("ACDL_LOCAL_TIER", None)
assert result["tier"] == "local-emulator"
assert result["backend"] == "local"
assert result["ecs"] is not None
@@ -196,11 +196,11 @@ def test_run_local_e2e_microservice():
def test_run_local_e2e_static_assets():
"""Static-assets stack has no ECS service; the local E2E must still
complete (ecs=None) and the outbox chain + Lambda stub must pass."""
os.environ["ACDL_LOCAL_TIER"] = "1"
os.environ["NOVA_LOCAL_TIER"] = "1"
try:
result = le.run_local_e2e("contracts/static-assets.yml")
finally:
os.environ.pop("ACDL_LOCAL_TIER", None)
os.environ.pop("NOVA_LOCAL_TIER", None); os.environ.pop("ACDL_LOCAL_TIER", None)
assert result["tier"] == "local-emulator"
assert result["ecs"] is None # no ECS service in this stack
assert result["outbox_chain_verified"] is True
@@ -209,9 +209,9 @@ def test_run_local_e2e_static_assets():
def test_is_local_tier_flag():
assert le.is_local_tier() is False
os.environ["ACDL_LOCAL_TIER"] = "1"
os.environ["NOVA_LOCAL_TIER"] = "1"
try:
assert le.is_local_tier() is True
finally:
os.environ.pop("ACDL_LOCAL_TIER", None)
os.environ.pop("NOVA_LOCAL_TIER", None); os.environ.pop("ACDL_LOCAL_TIER", None)
assert le.is_local_tier() is False
+11 -5
View File
@@ -376,7 +376,9 @@ class TestCli:
# ---------------------------------------------------------------------------
class TestKmsFailLoud:
"""P1-3: SSM publisher must fail loud when ACDL_KMS_KEY_ID is unset."""
"""P1-3: SSM publisher must fail loud when NOVA_KMS_KEY_ID is unset
(P2 renamed from ACDL_KMS_KEY_ID; dual-read NOVA_* preferred,
ACDL_* fallback until P5)."""
def test_kms_unset_raises(self, monkeypatch):
from moto import mock_aws
@@ -385,11 +387,14 @@ class TestKmsFailLoud:
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
# Both NOVA_* and ACDL_* unset → helper returns default (None) → fail loud.
monkeypatch.delenv("NOVA_KMS_KEY_ID", raising=False)
monkeypatch.delenv("ACDL_KMS_KEY_ID", raising=False)
monkeypatch.delenv("NOVA_ALLOW_DEFAULT_KMS", raising=False)
monkeypatch.delenv("ACDL_ALLOW_DEFAULT_KMS", raising=False)
with mock_aws():
with pytest.raises(RuntimeError, match="ACDL_KMS_KEY_ID is not set"):
with pytest.raises(RuntimeError, match="NOVA_KMS_KEY_ID is not set"):
publish_to_ssm({"vpc_id": "vpc-1"}, "dev", "c-1")
def test_kms_unset_allow_default_kms_escape_hatch(self, monkeypatch):
@@ -399,8 +404,9 @@ class TestKmsFailLoud:
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
monkeypatch.delenv("NOVA_KMS_KEY_ID", raising=False)
monkeypatch.delenv("ACDL_KMS_KEY_ID", raising=False)
monkeypatch.setenv("ACDL_ALLOW_DEFAULT_KMS", "1")
monkeypatch.setenv("NOVA_ALLOW_DEFAULT_KMS", "1")
with mock_aws():
ssm = boto3.client("ssm", region_name="us-east-1")
@@ -415,8 +421,8 @@ class TestKmsFailLoud:
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
monkeypatch.setenv("ACDL_KMS_KEY_ID", "arn:aws:kms:us-east-1:123:key/abc")
monkeypatch.setenv("ACDL_ALLOW_DEFAULT_KMS", "1")
monkeypatch.setenv("NOVA_KMS_KEY_ID", "arn:aws:kms:us-east-1:123:key/abc")
monkeypatch.setenv("NOVA_ALLOW_DEFAULT_KMS", "1")
from core.output_publisher import _kms_key_id
assert _kms_key_id() == "arn:aws:kms:us-east-1:123:key/abc"
+7 -7
View File
@@ -310,7 +310,7 @@ class TestDeployWorkflowConformance:
wf = _load_workflow(".gitea/workflows/deploy.yml")
inputs = wf["on"]["workflow_call"]["inputs"]
assert "contract" in inputs
assert inputs["contract"]["default"] == ".acdl/contract.yml"
assert inputs["contract"]["default"] == ".nova/contract.yml"
def test_deploy_workflow_has_mode_input(self):
wf = _load_workflow(".gitea/workflows/deploy.yml")
@@ -646,20 +646,20 @@ class TestModulesLifecyclePipeline:
assert set(inputs["lifecycle_mode"].get("options", [])) == {"plan", "full"}
def test_lifecycle_job_passes_mode_env_to_steps(self):
"""The lifecycle job sets ACDL_LIFECYCLE_MODE env so scripts dispatch
"""The lifecycle job sets NOVA_LIFECYCLE_MODE env so scripts dispatch
to plan-only by default, full on override."""
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
env = wf["jobs"]["lifecycle"].get("env", {})
assert "ACDL_LIFECYCLE_MODE" in env
assert "NOVA_LIFECYCLE_MODE" in env
# The expression must resolve to 'plan' when no input/var is set.
assert "plan" in env["ACDL_LIFECYCLE_MODE"]
assert "plan" in env["NOVA_LIFECYCLE_MODE"]
def test_l2_lifecycle_job_passes_mode_env_to_steps(self):
"""The L2 lifecycle job also sets ACDL_LIFECYCLE_MODE env."""
"""The L2 lifecycle job also sets NOVA_LIFECYCLE_MODE env."""
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
env = wf["jobs"]["l2-lifecycle"].get("env", {})
assert "ACDL_LIFECYCLE_MODE" in env
assert "plan" in env["ACDL_LIFECYCLE_MODE"]
assert "NOVA_LIFECYCLE_MODE" in env
assert "plan" in env["NOVA_LIFECYCLE_MODE"]
def test_ci_vpc_apply_skipped_in_plan_mode(self):
"""The CI VPC apply job is skipped in plan mode (nothing is applied)."""
+2
View File
@@ -29,6 +29,7 @@ def test_route_halt_publishes_to_sns_when_arn_set(monkeypatch):
def test_route_halt_falls_back_to_stderr_when_arn_unset(monkeypatch, capsys):
"""Without ACDL_SOD_HALT_TOPIC_ARN, a stderr emission occurs."""
monkeypatch.delenv("NOVA_SOD_HALT_TOPIC_ARN", raising=False)
monkeypatch.delenv("ACDL_SOD_HALT_TOPIC_ARN", raising=False)
# Mock outbox_writer.write_event to avoid AWS calls.
with mock.patch("core.outbox_writer.write_event", return_value=None):
@@ -40,6 +41,7 @@ def test_route_halt_falls_back_to_stderr_when_arn_unset(monkeypatch, capsys):
def test_route_halt_outbox_fallback_writes_event(monkeypatch):
"""Without the SNS ARN, the outbox fallback writes a SEPARATION_OF_DUTIES_VIOLATION event."""
monkeypatch.delenv("NOVA_SOD_HALT_TOPIC_ARN", raising=False)
monkeypatch.delenv("ACDL_SOD_HALT_TOPIC_ARN", raising=False)
with mock.patch("core.outbox_writer.write_event") as mock_write:
route_halt_artifact("contract-789", "sod violation", oncall_client=None)
+5 -3
View File
@@ -106,9 +106,11 @@ class TestCreateStateBackend:
"""terraform/bootstrap/create_state_backend.py — mock boto3."""
def test_state_bucket_name_construction(self, monkeypatch):
"""The state bucket name is derived from ACDL_AWS_ACCOUNT_ID."""
monkeypatch.setenv("ACDL_AWS_ACCOUNT_ID", "123456789012")
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
"""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)."""
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"