Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c19a5d66f2 | |||
| e8effef415 | |||
| 4ac68b7270 | |||
| 518bbe32a7 | |||
| ed36519223 | |||
| aa3e385606 |
@@ -0,0 +1,50 @@
|
|||||||
|
# GitHub Workflows — Nova Platform CI/CD Catalog
|
||||||
|
|
||||||
|
This directory contains the 7 GitHub Actions workflows for the Nova
|
||||||
|
platform. 3 are byte-identical Gitea mirrors (generated from
|
||||||
|
`workflows-src/` by `scripts/sync_workflows.py`, P8/REQ-172); 4 are
|
||||||
|
GitHub-only (Gitea act_runner feature gaps).
|
||||||
|
|
||||||
|
## Shared workflows (byte-identical Gitea + GitHub)
|
||||||
|
|
||||||
|
These 3 are generated from `workflows-src/<name>` by
|
||||||
|
`scripts/sync_workflows.py`; the `.gitea/workflows/<name>` mirror is kept
|
||||||
|
byte-identical. Run `python3 scripts/sync_workflows.py --check` to verify
|
||||||
|
no drift.
|
||||||
|
|
||||||
|
| Workflow | Trigger | Inputs | Required Secrets | Purpose |
|
||||||
|
|----------|---------|--------|------------------|---------|
|
||||||
|
| `ci.yml` | `pull_request: [main]` | — | — | Lint + test + check-only (runs on every PR) |
|
||||||
|
| `deploy.yml` | `workflow_call` (reusable) + `push: [main]` | `contract` (string, required), `mode` (string, default `deploy`), `changeRequestId` (string), `environment` (string) | `NOVA_AWS_ACCESS_KEY_ID`, `NOVA_AWS_SECRET_ACCESS_KEY`, `NOVA_AWS_DEFAULT_REGION`, `NOVA_KMS_KEY_ID`, `NOVA_LAMBDA_URL` | Reusable deploy workflow (invoked by consumer repos via `uses: acdl/.github/workflows/deploy.yml@v1.15`) |
|
||||||
|
| `modules-lifecycle.yml` | `pull_request: [main]` + `workflow_dispatch` | `lifecycle_mode` (string, default `plan` — `plan` or `full`) | `NOVA_AWS_ACCESS_KEY_ID`, `NOVA_AWS_SECRET_ACCESS_KEY`, `NOVA_AWS_DEFAULT_REGION`, `NOVA_AWS_ACCOUNT_ID` | L1 + L2 module lifecycle pipeline (plan-only default; full apply/modify/destroy on override) |
|
||||||
|
|
||||||
|
## GitHub-only workflows (no Gitea mirror)
|
||||||
|
|
||||||
|
These 4 have no Gitea counterpart (Gitea act_runner lacks the features
|
||||||
|
they require — reusable workflows, matrix `needs`, release API). See
|
||||||
|
`.gitea/workflows/README.md` for the limitation rationale.
|
||||||
|
|
||||||
|
| Workflow | Trigger | Inputs | Required Secrets | Purpose |
|
||||||
|
|----------|---------|--------|------------------|---------|
|
||||||
|
| `platform-test.yml` | `pull_request: [main]` | — | — | Lint + unit + integration + schema-validation (replaces `ci.yml` for PRs) |
|
||||||
|
| `primitives-plan.yml` | `pull_request: [main]` | — | `NOVA_AWS_*` | Plan-only for all L1 primitives (matrix) |
|
||||||
|
| `patterns-plan.yml` | `pull_request: [main]` | — | `NOVA_AWS_*` | Plan-only for all L2 modules (matrix) |
|
||||||
|
| `release.yml` | `push: [main]` | — | `NOVA_GITEA_TOKEN` (for Gitea release API) | Semver tag + MAJOR.MINOR/MAJOR floating-tag maintenance + release creation on merge to main |
|
||||||
|
|
||||||
|
## Reusable deploy workflow (`deploy.yml`)
|
||||||
|
|
||||||
|
Consumer repos invoke the deploy workflow via a versioned tag:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
uses: acdl/.github/workflows/deploy.yml@v1.15
|
||||||
|
with:
|
||||||
|
contract: .nova/contract.yml
|
||||||
|
environment: dev
|
||||||
|
secrets: inherit
|
||||||
|
```
|
||||||
|
|
||||||
|
The workflow checks out the consumer repo + the Nova platform repo, runs
|
||||||
|
`scripts/run_platform.sh`, and posts deploy outputs as a PR comment +
|
||||||
|
to SSM Parameter Store.
|
||||||
@@ -126,20 +126,46 @@ engine-specific code. `modules/`, `schemas/`, `contracts/`,
|
|||||||
|
|
||||||
## How to run
|
## How to run
|
||||||
|
|
||||||
### Prerequisites
|
### Quick start (offline, no AWS required)
|
||||||
|
|
||||||
> These prerequisites are for running the **platform repo** locally. A
|
The fastest way to verify the platform works — no AWS credentials, no
|
||||||
> consumer does not need any of these — see the
|
bootstrap, no cost. See the [Consumer guide](docs/consumer-guide.md)
|
||||||
> [Consumer guide](docs/consumer-guide.md) for the consumer happy path.
|
for the consumer happy path (a consumer owns only a contract + app code).
|
||||||
|
|
||||||
- A platform-managed environment (see [docs/environments/](docs/environments/)).
|
```bash
|
||||||
For local testing, `core/environments/dev.json` is provided as the sample.
|
# Install test dependencies
|
||||||
- AWS credentials for the dev environment (in `.env.secrets`, gitignored;
|
pip install -r requirements-test.txt
|
||||||
see [Credentials & zero-trust](#credentials--zero-trust)).
|
|
||||||
- `terraform` (pin `1.9.*`), `checkov` (pin `>=3.2,<4`), `python3` + `boto3`
|
|
||||||
+ `jsonschema`.
|
|
||||||
|
|
||||||
### Run the platform pipeline end-to-end
|
# 1. Run the test suite (all offline — uses moto for DynamoDB mocking)
|
||||||
|
python3 -m pytest tests/ -v
|
||||||
|
|
||||||
|
# 2. Run the platform in check-only mode (offline — contract -> resolver ->
|
||||||
|
# adapter -> structure validation). Uses the default sample contract
|
||||||
|
# (contracts/static-assets.yaml) + sample dev environment.
|
||||||
|
bash scripts/run_platform.sh --check-only
|
||||||
|
# Expected: "=== PLATFORM CHECK OK ==="
|
||||||
|
|
||||||
|
# 3. Run the headline E2E against the local emulating tier (emulates ECS,
|
||||||
|
# outbox, S3 state, Lambda in-process; D-092).
|
||||||
|
bash scripts/run_platform.sh --local
|
||||||
|
# Expected: "=== LOCAL E2E OK ==="
|
||||||
|
|
||||||
|
# 4. Reproduce the full CI pipeline locally (lint -> test -> check-only)
|
||||||
|
bash scripts/run_ci.sh
|
||||||
|
# Expected: "=== CI PIPELINE OK ==="
|
||||||
|
|
||||||
|
# Show all run_platform.sh flags:
|
||||||
|
bash scripts/run_platform.sh --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run against live AWS (requires credentials + bootstrap)
|
||||||
|
|
||||||
|
> Prerequisites: a platform-managed environment (see
|
||||||
|
> [docs/environments/](docs/environments/); `core/environments/dev.json`
|
||||||
|
> is the sample), AWS credentials for dev (in `.env.secrets`, gitignored;
|
||||||
|
> see [Credentials & zero-trust](#credentials--zero-trust)), `terraform`
|
||||||
|
> (pin `1.9.*`), `checkov` (pin `>=3.2,<4`), `python3` + `boto3` +
|
||||||
|
> `jsonschema`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Bootstrap the AWS state backend + runner IAM user (one-time, idempotent)
|
# 1. Bootstrap the AWS state backend + runner IAM user (one-time, idempotent)
|
||||||
@@ -168,26 +194,6 @@ bash scripts/run_platform.sh --plan-only contracts/static-assets.yaml
|
|||||||
bash scripts/run_platform.sh --quiet contracts/static-assets.yaml
|
bash scripts/run_platform.sh --quiet contracts/static-assets.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test the platform (offline, no AWS required)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install test dependencies
|
|
||||||
pip install -r requirements-test.txt
|
|
||||||
|
|
||||||
# Run the test suite (all offline — uses moto for DynamoDB mocking)
|
|
||||||
python3 -m pytest tests/ -v
|
|
||||||
|
|
||||||
# Run the platform in check-only mode (offline — no AWS, no policy checks,
|
|
||||||
# no outbox). Uses the default sample contract (contracts/static-assets.yaml)
|
|
||||||
# and the sample dev environment (core/environments/dev.json).
|
|
||||||
bash scripts/run_platform.sh --check-only
|
|
||||||
# Expected: "=== PLATFORM CHECK OK ==="
|
|
||||||
|
|
||||||
# Reproduce the full CI pipeline locally (lint -> test -> check-only)
|
|
||||||
bash scripts/run_ci.sh
|
|
||||||
# Expected: "=== CI PIPELINE OK ==="
|
|
||||||
```
|
|
||||||
|
|
||||||
### CI/CD pipelines
|
### CI/CD pipelines
|
||||||
|
|
||||||
The CI/CD pipeline is defined by a **central pipeline contract** — a
|
The CI/CD pipeline is defined by a **central pipeline contract** — a
|
||||||
|
|||||||
@@ -64,6 +64,21 @@ def _load_json(path):
|
|||||||
return json.load(fh)
|
return json.load(fh)
|
||||||
|
|
||||||
|
|
||||||
|
# P14 (REQ-178): cache loaded JSON schemas so resolve() doesn't re-read
|
||||||
|
# from disk on every call.
|
||||||
|
_SCHEMA_CACHE: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_schema(path):
|
||||||
|
"""Load a JSON schema with caching (P14, REQ-178)."""
|
||||||
|
cached = _SCHEMA_CACHE.get(path)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
schema = _load_json(path)
|
||||||
|
_SCHEMA_CACHE[path] = schema
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
def _load_yaml(path):
|
def _load_yaml(path):
|
||||||
with open(path, "r") as fh:
|
with open(path, "r") as fh:
|
||||||
return yaml.safe_load(fh)
|
return yaml.safe_load(fh)
|
||||||
@@ -468,7 +483,7 @@ def resolve(contract_path, repo_root=None, environment_override=None):
|
|||||||
contract["environment"] = environment_override
|
contract["environment"] = environment_override
|
||||||
|
|
||||||
# Load schemas
|
# Load schemas
|
||||||
contract_schema = _load_json(os.path.join(repo_root, "schemas", "contract.schema.json"))
|
contract_schema = _load_schema(os.path.join(repo_root, "schemas", "contract.schema.json"))
|
||||||
|
|
||||||
# Validate contract against schema
|
# Validate contract against schema
|
||||||
jsonschema.validate(contract, contract_schema)
|
jsonschema.validate(contract, contract_schema)
|
||||||
@@ -588,7 +603,7 @@ def resolve(contract_path, repo_root=None, environment_override=None):
|
|||||||
stack_instance["outputs"] = merged_outputs
|
stack_instance["outputs"] = merged_outputs
|
||||||
|
|
||||||
# Validate against stack schema
|
# Validate against stack schema
|
||||||
stack_schema = _load_json(os.path.join(repo_root, "schemas", "stack.schema.json"))
|
stack_schema = _load_schema(os.path.join(repo_root, "schemas", "stack.schema.json"))
|
||||||
jsonschema.validate(stack_instance, stack_schema)
|
jsonschema.validate(stack_instance, stack_schema)
|
||||||
|
|
||||||
return stack_instance
|
return stack_instance
|
||||||
|
|||||||
@@ -398,6 +398,65 @@ def _validate_change_request(payload):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _onboard_consumer(payload):
|
||||||
|
"""P18 (REQ-182): accept a self-service onboarding request.
|
||||||
|
|
||||||
|
Validates the payload against schemas/onboarding.schema.json, then
|
||||||
|
writes a 'pending' row to nova-contracts (D-119). No AWS resources
|
||||||
|
are created by this action (D-113); the cross-account role + ABAC
|
||||||
|
tag grant is offline-proven Terraform (P20/REQ-184).
|
||||||
|
"""
|
||||||
|
import jsonschema
|
||||||
|
schema_path = os.path.join(os.path.dirname(os.path.dirname(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"schemas", "onboarding.schema.json")
|
||||||
|
try:
|
||||||
|
with open(schema_path) as f:
|
||||||
|
schema = json.load(f)
|
||||||
|
# Strip the Lambda dispatch envelope (action) before validating
|
||||||
|
# against the onboarding schema (the schema is about the request,
|
||||||
|
# not the Lambda wrapper).
|
||||||
|
onboarding_payload = {k: v for k, v in payload.items() if k != "action"}
|
||||||
|
jsonschema.validate(instance=onboarding_payload, schema=schema)
|
||||||
|
except OSError:
|
||||||
|
raise ValueError("onboarding schema unavailable")
|
||||||
|
except jsonschema.ValidationError as e:
|
||||||
|
raise ValueError(f"onboarding payload invalid: {e.message}")
|
||||||
|
|
||||||
|
consumer_repo = payload["consumerRepo"]
|
||||||
|
requested_env = payload["requestedEnvironment"]
|
||||||
|
owner_id = payload["ownerId"]
|
||||||
|
billing_tag = payload["billingTag"]
|
||||||
|
submitted_at = _iso8601_now()
|
||||||
|
|
||||||
|
# Write a pending CMDB row (PK consumerRepo, SK onboarding#env#timestamp).
|
||||||
|
table = _get_dynamodb().Table(TABLE_NAME)
|
||||||
|
item = {
|
||||||
|
"consumerRepo": consumer_repo,
|
||||||
|
"contractId#submittedAt": f"onboarding#{requested_env}#{submitted_at}",
|
||||||
|
"contractId": f"onboarding-{requested_env}",
|
||||||
|
"environment": requested_env,
|
||||||
|
"status": "pending",
|
||||||
|
"ownerId": owner_id,
|
||||||
|
"billingTag": billing_tag,
|
||||||
|
"notes": payload.get("notes", ""),
|
||||||
|
"submittedAt": submitted_at,
|
||||||
|
}
|
||||||
|
table.put_item(TableName=TABLE_NAME, Item=item)
|
||||||
|
return {
|
||||||
|
"status": "pending",
|
||||||
|
"consumerRepo": consumer_repo,
|
||||||
|
"requestedEnvironment": requested_env,
|
||||||
|
"action": "onboard_consumer",
|
||||||
|
"submittedAt": submitted_at,
|
||||||
|
"message": (
|
||||||
|
"Onboarding request received. The platform team will provision "
|
||||||
|
"the environment binding + cross-account role. Track the status "
|
||||||
|
"via the nova-contracts table (status=pending → granted)."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def lambda_handler(event, context):
|
def lambda_handler(event, context):
|
||||||
"""AWS Lambda handler entry point.
|
"""AWS Lambda handler entry point.
|
||||||
|
|
||||||
@@ -426,6 +485,8 @@ def lambda_handler(event, context):
|
|||||||
result = _report_error(payload)
|
result = _report_error(payload)
|
||||||
elif action == "validate_change_request":
|
elif action == "validate_change_request":
|
||||||
result = _validate_change_request(payload)
|
result = _validate_change_request(payload)
|
||||||
|
elif action == "onboard_consumer":
|
||||||
|
result = _onboard_consumer(payload)
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"statusCode": 400,
|
"statusCode": 400,
|
||||||
|
|||||||
@@ -38,8 +38,10 @@ from core import env as _envhelper
|
|||||||
SSM_PREFIX = "/nova"
|
SSM_PREFIX = "/nova"
|
||||||
KMS_KEY_ID_ENV = "NOVA_KMS_KEY_ID"
|
KMS_KEY_ID_ENV = "NOVA_KMS_KEY_ID"
|
||||||
|
|
||||||
# Outputs that are safe to display in a PR comment (no secrets).
|
# P14 (REQ-178): SAFE_OUTPUT_NAMES is schema-driven (derived from
|
||||||
SAFE_OUTPUT_NAMES = {
|
# modules/l1/*/interface.json outputs that don't have sensitive:true).
|
||||||
|
# Falls back to the hardcoded set if the interfaces can't be read.
|
||||||
|
_HARDCODED_SAFE_OUTPUTS = {
|
||||||
"distribution_domain_name",
|
"distribution_domain_name",
|
||||||
"bucket_arn",
|
"bucket_arn",
|
||||||
"bucket_name",
|
"bucket_name",
|
||||||
@@ -59,6 +61,37 @@ SAFE_OUTPUT_NAMES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_safe_output_names():
|
||||||
|
"""Derive the safe-output allowlist from interface.json outputs.
|
||||||
|
|
||||||
|
P14 (REQ-178): scan modules/l1/*/interface.json; an output is safe if
|
||||||
|
its spec does not set sensitive:true. Falls back to the hardcoded set
|
||||||
|
if no interfaces are readable.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
safe = set()
|
||||||
|
try:
|
||||||
|
for iface in (root / "modules" / "l1").glob("*/interface.json"):
|
||||||
|
d = json.loads(iface.read_text())
|
||||||
|
outs = d.get("outputs", {})
|
||||||
|
if isinstance(outs, dict):
|
||||||
|
for name, spec in outs.items():
|
||||||
|
if not (isinstance(spec, dict) and spec.get("sensitive")):
|
||||||
|
safe.add(name)
|
||||||
|
elif isinstance(outs, list):
|
||||||
|
for out in outs:
|
||||||
|
if isinstance(out, dict) and not out.get("sensitive"):
|
||||||
|
safe.add(out.get("name", ""))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return safe or _HARDCODED_SAFE_OUTPUTS
|
||||||
|
|
||||||
|
|
||||||
|
SAFE_OUTPUT_NAMES = _load_safe_output_names()
|
||||||
|
|
||||||
|
|
||||||
def _ssm_client():
|
def _ssm_client():
|
||||||
if boto3 is None:
|
if boto3 is None:
|
||||||
raise RuntimeError("boto3 is required for SSM publishing")
|
raise RuntimeError("boto3 is required for SSM publishing")
|
||||||
|
|||||||
@@ -668,17 +668,9 @@ def write_report(report: RegressionReport,
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
milestone = _envhelper.get_env("REGRESSION_MILESTONE", "v1.10") or "v1.10"
|
"""P13 (REQ-177): re-export from core.regression_verify_cli."""
|
||||||
phase = int(_envhelper.get_env("REGRESSION_PHASE", "52") or "52")
|
from core.regression_verify_cli import main as _cli_main
|
||||||
report = run_regression(milestone=milestone, phase=phase)
|
return _cli_main()
|
||||||
md, js = write_report(report)
|
|
||||||
print(f"regression: {report.summary} -> {md}")
|
|
||||||
if not report.passed:
|
|
||||||
print("FAIL: regression surfaced non-Verified/non-Skipped capabilities "
|
|
||||||
"(milestone gate blocks)", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
print(f"regression: gate passes (summary={report.summary})")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Nova Regression Verify CLI — command-line entry point.
|
||||||
|
|
||||||
|
Extracted from core/regression_verify.py (P13, REQ-177).
|
||||||
|
|
||||||
|
G-113 import direction: this module imports core.regression_verify (the
|
||||||
|
library) for run_regression + write_report. The library does not import
|
||||||
|
this CLI module. Nothing imports this CLI except direct invocation.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from core import env as _envhelper
|
||||||
|
from core.regression_verify import run_regression, write_report
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
"""CLI: run the regression gate and write the report."""
|
||||||
|
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}")
|
||||||
|
if not report.passed:
|
||||||
|
print("FAIL: regression surfaced non-Verified/non-Skipped capabilities "
|
||||||
|
"(milestone gate blocks)", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"regression: gate passes (summary={report.summary})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://nova.cloudinit.dev/schemas/onboarding.schema.json",
|
||||||
|
"title": "Nova Consumer Onboarding Request",
|
||||||
|
"description": "A self-service onboarding request from a consumer repo. Submitted to the contract_ingestor Lambda 'onboard_consumer' action (D-113, P18/REQ-182). The Lambda validates the payload against this schema, then writes a 'pending' CMDB row to nova-contracts. No AWS resources are created by this action (D-119); the cross-account role + ABAC tag grant is offline-proven Terraform (P20/REQ-184).",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["consumerRepo", "requestedEnvironment", "ownerId", "billingTag"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"consumerRepo": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The consumer repository in org/repo format.",
|
||||||
|
"pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$",
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"requestedEnvironment": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The environment the consumer requests (must exist as a core/environments/<name>.json).",
|
||||||
|
"enum": ["dev", "qa", "prod", "dr"]
|
||||||
|
},
|
||||||
|
"ownerId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The owning team or individual (for ABAC nova:owner tag + CMDB).",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 64
|
||||||
|
},
|
||||||
|
"billingTag": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The cost-center / billing tag for the consumer's resources.",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 64
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional free-form notes for the platform team.",
|
||||||
|
"maxLength": 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
# run_platform.sh --plan-only <contract.yml> (AWS plan only, no Checkov/outbox)
|
# run_platform.sh --plan-only <contract.yml> (AWS plan only, no Checkov/outbox)
|
||||||
# run_platform.sh --apply <contract.yml> (AWS apply: init/validate/plan/apply)
|
# run_platform.sh --apply <contract.yml> (AWS apply: init/validate/plan/apply)
|
||||||
# run_platform.sh --destroy <contract.yml> (AWS destroy: init/validate/destroy)
|
# run_platform.sh --destroy <contract.yml> (AWS destroy: init/validate/destroy)
|
||||||
|
# run_platform.sh --local [contract.yml] (local emulating tier, no AWS)
|
||||||
|
# run_platform.sh --decommission <CR> <contract.yml> (gated teardown)
|
||||||
|
# run_platform.sh --help (show all flags)
|
||||||
#
|
#
|
||||||
# Modes:
|
# Modes:
|
||||||
# --check-only (offline, no AWS/Checkov/DynamoDB — for CI)
|
# --check-only (offline, no AWS/Checkov/DynamoDB — for CI)
|
||||||
@@ -17,6 +20,8 @@
|
|||||||
# contract -> resolver -> stack -> adapter -> terraform init/validate/plan/apply -> exit 0
|
# contract -> resolver -> stack -> adapter -> terraform init/validate/plan/apply -> exit 0
|
||||||
# --destroy (requires AWS creds; use --decommission <CR> for gated production teardown)
|
# --destroy (requires AWS creds; use --decommission <CR> for gated production teardown)
|
||||||
# contract -> resolver -> stack -> adapter -> terraform init/validate/destroy -> exit 0
|
# contract -> resolver -> stack -> adapter -> terraform init/validate/destroy -> exit 0
|
||||||
|
# --local (no AWS creds; local emulating tier D-092)
|
||||||
|
# contract -> resolver -> adapter -> local S3/ECS/outbox/Lambda stubs -> exit 0
|
||||||
# (default) (requires AWS creds + Checkov + DynamoDB)
|
# (default) (requires AWS creds + Checkov + DynamoDB)
|
||||||
# contract -> resolver -> stack -> adapter -> terraform plan -> Checkov ->
|
# contract -> resolver -> stack -> adapter -> terraform plan -> Checkov ->
|
||||||
# confidence -> outbox
|
# confidence -> outbox
|
||||||
@@ -24,6 +29,10 @@
|
|||||||
# Flags:
|
# Flags:
|
||||||
# --quiet suppress terraform/checkov streaming (output to log only)
|
# --quiet suppress terraform/checkov streaming (output to log only)
|
||||||
# --decommission gate --destroy with D-070 two-step CR validation (requires <CR>)
|
# --decommission gate --destroy with D-070 two-step CR validation (requires <CR>)
|
||||||
|
# --deploy-uptime deploy the uptime monitoring stack (separate state)
|
||||||
|
# --local run the headline E2E against the local emulating tier (D-092)
|
||||||
|
# --environment <name> override the contract's environment at load time (D-088)
|
||||||
|
# --help, -h show all flags + a one-line description
|
||||||
#
|
#
|
||||||
# The contract file is a YAML file validated against schemas/contract.schema.json.
|
# The contract file is a YAML file validated against schemas/contract.schema.json.
|
||||||
# The resolver (core/contract_resolver.py) resolves it to a Target Stack
|
# The resolver (core/contract_resolver.py) resolves it to a Target Stack
|
||||||
@@ -59,6 +68,36 @@ CHANGE_REQUEST_ID=""
|
|||||||
ENVIRONMENT_OVERRIDE=""
|
ENVIRONMENT_OVERRIDE=""
|
||||||
CONTRACT=""
|
CONTRACT=""
|
||||||
|
|
||||||
|
# P15 (REQ-179): --help / -h prints all flags + a one-line description.
|
||||||
|
_print_help() {
|
||||||
|
cat <<'HELP'
|
||||||
|
Nova platform pipeline — run_platform.sh
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
run_platform.sh <contract.yml> (full e2e with AWS)
|
||||||
|
run_platform.sh --check-only [contract.yml] (offline, no AWS)
|
||||||
|
run_platform.sh --plan-only <contract.yml> (AWS plan only)
|
||||||
|
run_platform.sh --apply <contract.yml> (AWS apply)
|
||||||
|
run_platform.sh --destroy <contract.yml> (AWS destroy)
|
||||||
|
run_platform.sh --local [contract.yml] (local emulating tier)
|
||||||
|
run_platform.sh --decommission <CR> <contract.yml> (gated teardown)
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--check-only Offline validation (no AWS/Checkov/DynamoDB) — for CI
|
||||||
|
--plan-only AWS plan only (requires AWS creds, no Checkov/outbox)
|
||||||
|
--apply AWS apply: init/validate/plan/apply (HITL gate for qa/prod/dr)
|
||||||
|
--destroy AWS destroy: init/validate/destroy
|
||||||
|
--decommission Gate --destroy with D-070 two-step CR validation (requires <CR>)
|
||||||
|
--local Run the headline E2E against the local emulating tier (D-092, no AWS)
|
||||||
|
--quiet Suppress terraform/checkov streaming (log only)
|
||||||
|
--deploy-uptime Deploy the uptime monitoring stack (separate state)
|
||||||
|
--environment <name> Override the contract's environment at load time (D-088)
|
||||||
|
--help, -h Show this help
|
||||||
|
|
||||||
|
The contract file is a YAML file validated against schemas/contract.schema.json.
|
||||||
|
HELP
|
||||||
|
}
|
||||||
|
|
||||||
# Parse args; --environment takes a value (either --environment=VALUE or
|
# Parse args; --environment takes a value (either --environment=VALUE or
|
||||||
# --environment VALUE). The contract / changeRequestId are the remaining
|
# --environment VALUE). The contract / changeRequestId are the remaining
|
||||||
# positional args.
|
# positional args.
|
||||||
@@ -69,6 +108,7 @@ for arg in "$@"; do
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
|
--help|-h) _print_help; exit 0 ;;
|
||||||
--check-only) CHECK_ONLY=1 ;;
|
--check-only) CHECK_ONLY=1 ;;
|
||||||
--plan-only) PLAN_ONLY=1 ;;
|
--plan-only) PLAN_ONLY=1 ;;
|
||||||
--apply) APPLY_ONLY=1 ;;
|
--apply) APPLY_ONLY=1 ;;
|
||||||
|
|||||||
@@ -594,3 +594,51 @@ class TestV14IdentityValidation:
|
|||||||
docstring = ingestor._validate_caller_identity.__doc__
|
docstring = ingestor._validate_caller_identity.__doc__
|
||||||
assert "ABAC" in docstring
|
assert "ABAC" in docstring
|
||||||
assert "PrincipalTag" in docstring
|
assert "PrincipalTag" in docstring
|
||||||
|
|
||||||
|
class TestOnboardConsumer:
|
||||||
|
"""P18 (REQ-182): the onboard_consumer action writes a pending CMDB row."""
|
||||||
|
|
||||||
|
_ARN = "arn:aws:sts::000:assumed-role/nova-deploy/test"
|
||||||
|
|
||||||
|
def test_valid_onboarding_writes_pending_row(self, moto_contracts_table):
|
||||||
|
payload = {
|
||||||
|
"action": "onboard_consumer",
|
||||||
|
"consumerRepo": "acdl/consumer-b",
|
||||||
|
"requestedEnvironment": "dev",
|
||||||
|
"ownerId": "team-b",
|
||||||
|
"billingTag": "cost-center-b",
|
||||||
|
}
|
||||||
|
event = {"body": json.dumps(payload), "requestContext": {"identity": {"userArn": self._ARN}}}
|
||||||
|
resp = ingestor.lambda_handler(event, None)
|
||||||
|
assert resp["statusCode"] == 200
|
||||||
|
body = json.loads(resp["body"])
|
||||||
|
assert body["status"] == "pending"
|
||||||
|
assert body["action"] == "onboard_consumer"
|
||||||
|
assert body["requestedEnvironment"] == "dev"
|
||||||
|
|
||||||
|
def test_invalid_onboarding_rejected(self, moto_contracts_table):
|
||||||
|
# An invalid consumerRepo (no /) fails the identity format check
|
||||||
|
# (which runs for all actions) before the onboarding schema.
|
||||||
|
payload = {
|
||||||
|
"action": "onboard_consumer",
|
||||||
|
"consumerRepo": "not-a-repo-format",
|
||||||
|
"requestedEnvironment": "dev",
|
||||||
|
"ownerId": "team-b",
|
||||||
|
"billingTag": "cost-center-b",
|
||||||
|
}
|
||||||
|
event = {"body": json.dumps(payload), "requestContext": {"identity": {"userArn": self._ARN}}}
|
||||||
|
resp = ingestor.lambda_handler(event, None)
|
||||||
|
assert resp["statusCode"] == 400
|
||||||
|
assert "invalid consumerRepo" in json.loads(resp["body"])["error"]
|
||||||
|
|
||||||
|
def test_missing_onboarding_field_rejected(self, moto_contracts_table):
|
||||||
|
payload = {
|
||||||
|
"action": "onboard_consumer",
|
||||||
|
"consumerRepo": "acdl/consumer-b",
|
||||||
|
"requestedEnvironment": "dev",
|
||||||
|
# ownerId + billingTag missing
|
||||||
|
}
|
||||||
|
event = {"body": json.dumps(payload), "requestContext": {"identity": {"userArn": self._ARN}}}
|
||||||
|
resp = ingestor.lambda_handler(event, None)
|
||||||
|
assert resp["statusCode"] == 400
|
||||||
|
assert "onboarding payload invalid" in json.loads(resp["body"])["error"]
|
||||||
|
|||||||
@@ -35,3 +35,14 @@ class TestDocsCoverage:
|
|||||||
assert "How to Wire" in content
|
assert "How to Wire" in content
|
||||||
assert "How to Test" in content
|
assert "How to Test" in content
|
||||||
assert "Existing Adapters" in content
|
assert "Existing Adapters" in content
|
||||||
|
|
||||||
|
def test_github_workflows_readme_catalogs_all_workflows():
|
||||||
|
"""P16 (REQ-180): .github/workflows/README.md catalogs all 7 workflows."""
|
||||||
|
from pathlib import Path
|
||||||
|
readme = Path(__file__).resolve().parent.parent / ".github" / "workflows" / "README.md"
|
||||||
|
assert readme.is_file(), ".github/workflows/README.md missing"
|
||||||
|
text = readme.read_text()
|
||||||
|
for wf in ["ci.yml", "deploy.yml", "modules-lifecycle.yml",
|
||||||
|
"platform-test.yml", "primitives-plan.yml", "patterns-plan.yml",
|
||||||
|
"release.yml"]:
|
||||||
|
assert wf in text, f"{wf} not cataloged in .github/workflows/README.md"
|
||||||
|
|||||||
Reference in New Issue
Block a user