Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe312c6292 | |||
| c19a5d66f2 | |||
| e8effef415 | |||
| 4ac68b7270 |
@@ -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,34 +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 ==="
|
|
||||||
|
|
||||||
# Run the headline E2E against the local emulating tier (no AWS credentials
|
|
||||||
# needed — emulates ECS, outbox, S3 state, Lambda in-process; D-092).
|
|
||||||
bash scripts/run_platform.sh --local
|
|
||||||
# Expected: "=== LOCAL E2E OK ==="
|
|
||||||
|
|
||||||
# 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 + a one-line description each.
|
|
||||||
bash scripts/run_platform.sh --help
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ def load(env_name, root=None):
|
|||||||
|
|
||||||
|
|
||||||
def _onboarding_message(env_name):
|
def _onboarding_message(env_name):
|
||||||
|
# P19 (REQ-183): rebranded Nova self-service request path — no longer
|
||||||
|
# routes to "contact the platform team" for the request step.
|
||||||
return (
|
return (
|
||||||
"=== Nova Environment Onboarding ===\n"
|
"=== Nova Environment Onboarding ===\n"
|
||||||
f"No environment named '{env_name}' is bound to this repository.\n\n"
|
f"No environment named '{env_name}' is bound to this repository.\n\n"
|
||||||
@@ -66,13 +68,15 @@ def _onboarding_message(env_name):
|
|||||||
" - an IAM role surfaced to your repo via attribute-based\n"
|
" - an IAM role surfaced to your repo via attribute-based\n"
|
||||||
" authorization (ABAC)\n\n"
|
" authorization (ABAC)\n\n"
|
||||||
"You do not provide an AWS account, VPC, subnet, or state bucket.\n\n"
|
"You do not provide an AWS account, VPC, subnet, or state bucket.\n\n"
|
||||||
"To request an environment:\n"
|
"To request an environment (self-service):\n"
|
||||||
" 1. Contact the platform team with your repo name + the\n"
|
" 1. Submit an onboarding request to the Nova Lambda\n"
|
||||||
|
" (action: onboard_consumer) with your repo name + the\n"
|
||||||
" environment name you need (e.g. 'dev').\n"
|
" environment name you need (e.g. 'dev').\n"
|
||||||
" 2. The platform team provisions the account/network/state/role\n"
|
" 2. The platform generates an environment binding + opens a PR.\n"
|
||||||
" and binds the environment to your repo.\n"
|
" 3. The platform provisions the account/network/state/role and\n"
|
||||||
" 3. Your next pipeline run will proceed normally.\n\n"
|
" grants the ABAC role. Your next pipeline run proceeds.\n\n"
|
||||||
"Expected turnaround: contact the platform team for current SLA.\n"
|
"Run: python3 core/onboarding.py --request '{...}' to generate a\n"
|
||||||
|
"binding file locally, or POST to the Lambda onboard_consumer action.\n"
|
||||||
"===================================\n"
|
"===================================\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -33,5 +33,13 @@ halting the pipeline before any work is done.
|
|||||||
|
|
||||||
A new environment is a platform-team action: provision the AWS account /
|
A new environment is a platform-team action: provision the AWS account /
|
||||||
network / state backend / IAM role, then add a `<name>.json` here and bind
|
network / state backend / IAM role, then add a `<name>.json` here and bind
|
||||||
it to the consumer repo. Self-service environment provisioning is on the
|
it to the consumer repo.
|
||||||
roadmap; today it is a platform-team action.
|
|
||||||
|
**P19 (REQ-183):** the *request* step is now self-service. A consumer
|
||||||
|
submits an onboarding request (POST to the Nova Lambda `onboard_consumer`
|
||||||
|
action, or `python3 core/onboarding.py --request '{...}'`) and the
|
||||||
|
platform generates a `<name>.json` binding file from the request + opens
|
||||||
|
a PR. The actual AWS account/network/state provisioning + cross-account
|
||||||
|
role grant remains a platform-team action (a future feature milestone
|
||||||
|
will automate the provisioning; the cross-account role Terraform is
|
||||||
|
offline-proven in P20/REQ-184).
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Nova Onboarding — auto-generate an environment binding file (P19, REQ-183).
|
||||||
|
|
||||||
|
Given a consumer onboarding request (validated against
|
||||||
|
schemas/onboarding.schema.json), generate a ``<env>.json`` environment
|
||||||
|
binding file from the dev template, filling in the consumer's ownerId +
|
||||||
|
billingTag. The generated file is a starting point for the platform team
|
||||||
|
(or a future automation) to bind to a real AWS account.
|
||||||
|
|
||||||
|
This is the "request path" half of the no-humans onboarding flow (D-113).
|
||||||
|
Real AWS account/network/state provisioning is a future feature milestone;
|
||||||
|
this module removes the human handoff from the *request* step by
|
||||||
|
generating the binding file + emitting a git patch / PR-branch instruction.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 core/onboarding.py <request.json> [--out <env.json>]
|
||||||
|
python3 core/onboarding.py --request '{"consumerRepo":"acdl/c","requestedEnvironment":"qa","ownerId":"team-a","billingTag":"cc-a"}'
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_root() -> Path:
|
||||||
|
return Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _load_template_env(template_env: str = "dev", root: Path | None = None) -> Dict[str, Any]:
|
||||||
|
"""Load the template environment JSON (defaults to dev.json)."""
|
||||||
|
root = root or _repo_root()
|
||||||
|
env_path = root / "core" / "environments" / f"{template_env}.json"
|
||||||
|
if not env_path.is_file():
|
||||||
|
raise FileNotFoundError(f"template environment {env_path} not found")
|
||||||
|
return json.loads(env_path.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def generate_env_file(
|
||||||
|
request: Dict[str, Any],
|
||||||
|
template_env: str = "dev",
|
||||||
|
root: Path | None = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Generate an environment binding dict from a consumer onboarding request.
|
||||||
|
|
||||||
|
The generated dict is a copy of the template env with:
|
||||||
|
- ``name`` → the requested environment
|
||||||
|
- ``description`` → notes the consumer + owner
|
||||||
|
- ``account_id`` → placeholder (000000000000) for the platform team
|
||||||
|
to fill with the real account
|
||||||
|
- ``ownerId`` + ``billingTag`` → from the request (for ABAC + cost)
|
||||||
|
|
||||||
|
The dict validates against schemas/environment.schema.json.
|
||||||
|
|
||||||
|
Returns the generated env dict.
|
||||||
|
"""
|
||||||
|
template = _load_template_env(template_env, root)
|
||||||
|
requested = request["requestedEnvironment"]
|
||||||
|
owner = request["ownerId"]
|
||||||
|
billing = request["billingTag"]
|
||||||
|
consumer = request["consumerRepo"]
|
||||||
|
|
||||||
|
env = dict(template)
|
||||||
|
env["name"] = requested
|
||||||
|
env["description"] = (
|
||||||
|
f"Auto-generated binding for {consumer} (owner={owner}, "
|
||||||
|
f"billing={billing}). Replace account_id with the real "
|
||||||
|
f"{requested} account before deploying."
|
||||||
|
)
|
||||||
|
env["account_id"] = "000000000000" # placeholder — platform team fills
|
||||||
|
env["ownerId"] = owner
|
||||||
|
env["billingTag"] = billing
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _onboarding_request_message(env_name: str) -> str:
|
||||||
|
"""P19 (REQ-183): the rebranded Nova onboarding message — self-service
|
||||||
|
request path, no longer routes to 'contact the platform team'."""
|
||||||
|
return (
|
||||||
|
"=== Nova Environment Onboarding ===\n"
|
||||||
|
f"No environment named '{env_name}' is bound to this repository.\n\n"
|
||||||
|
"Nova environments are platform-managed. The platform provisions on\n"
|
||||||
|
"your behalf:\n"
|
||||||
|
" - an AWS account (or a scoped partition of one)\n"
|
||||||
|
" - a network (VPC + subnets)\n"
|
||||||
|
" - a state backend (an S3 bucket + DynamoDB lock table)\n"
|
||||||
|
" - an IAM role surfaced to your repo via attribute-based\n"
|
||||||
|
" authorization (ABAC)\n\n"
|
||||||
|
"You do not provide an AWS account, VPC, subnet, or state bucket.\n\n"
|
||||||
|
"To request an environment (self-service):\n"
|
||||||
|
" 1. Submit an onboarding request to the Nova Lambda\n"
|
||||||
|
" (action: onboard_consumer) with your repo name + the\n"
|
||||||
|
" environment name you need (e.g. 'dev').\n"
|
||||||
|
" 2. The platform generates an environment binding + opens a PR.\n"
|
||||||
|
" 3. The platform provisions the account/network/state/role and\n"
|
||||||
|
" grants the ABAC role. Your next pipeline run proceeds.\n\n"
|
||||||
|
"Run: python3 core/onboarding.py --request '{...}' to generate a\n"
|
||||||
|
"binding file locally, or POST to the Lambda onboard_consumer action.\n"
|
||||||
|
"===================================\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Generate an env binding from an onboarding request.")
|
||||||
|
group = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
group.add_argument("request_file", nargs="?", help="path to a request JSON file")
|
||||||
|
group.add_argument("--request", help="inline request JSON string")
|
||||||
|
parser.add_argument("--out", help="output path for the generated env JSON (default: stdout)")
|
||||||
|
parser.add_argument("--template-env", default="dev", help="template environment (default: dev)")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.request:
|
||||||
|
request = json.loads(args.request)
|
||||||
|
else:
|
||||||
|
request = json.loads(Path(args.request_file).read_text())
|
||||||
|
|
||||||
|
env = generate_env_file(request, template_env=args.template_env)
|
||||||
|
env_json = json.dumps(env, indent=2) + "\n"
|
||||||
|
if args.out:
|
||||||
|
Path(args.out).write_text(env_json)
|
||||||
|
print(f"wrote: {args.out}")
|
||||||
|
else:
|
||||||
|
print(env_json)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ class TestEnvironmentCheck:
|
|||||||
assert ok is False
|
assert ok is False
|
||||||
assert "nonexistent-env" in msg
|
assert "nonexistent-env" in msg
|
||||||
assert "onboarding" in msg.lower() or "Environment Onboarding" in msg
|
assert "onboarding" in msg.lower() or "Environment Onboarding" in msg
|
||||||
assert "platform team" in msg.lower()
|
# P19 (REQ-183): the message now routes to the self-service
|
||||||
|
# request path (onboard_consumer), not "contact the platform team".
|
||||||
|
assert "platform team" not in msg.lower()
|
||||||
|
assert "onboard_consumer" in msg or "self-service" in msg.lower()
|
||||||
|
|
||||||
def test_onboarding_message_lists_platform_provisions(self):
|
def test_onboarding_message_lists_platform_provisions(self):
|
||||||
msg = _onboarding_message("qa")
|
msg = _onboarding_message("qa")
|
||||||
@@ -103,3 +106,15 @@ class TestRunPlatformWireIn:
|
|||||||
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
|
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
|
||||||
assert "PLATFORM CHECK OK" in result.stdout
|
assert "PLATFORM CHECK OK" in result.stdout
|
||||||
assert "environment" in result.stdout.lower() or "Step 0" in result.stdout
|
assert "environment" in result.stdout.lower() or "Step 0" in result.stdout
|
||||||
|
|
||||||
|
class TestOnboardingMessageSelfService:
|
||||||
|
"""P19 (REQ-183): the onboarding message is self-service, not 'contact
|
||||||
|
the platform team'."""
|
||||||
|
|
||||||
|
def test_no_contact_platform_team(self):
|
||||||
|
msg = _onboarding_message("qa")
|
||||||
|
assert "contact the platform team" not in msg.lower()
|
||||||
|
|
||||||
|
def test_mentions_self_service_request(self):
|
||||||
|
msg = _onboarding_message("qa")
|
||||||
|
assert "self-service" in msg.lower() or "onboard_consumer" in msg
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Unit tests for core/onboarding.py (P19, REQ-183)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from core.onboarding import generate_env_file, _onboarding_request_message
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateEnvFile:
|
||||||
|
"""P19 (REQ-183): generate_env_file produces a valid env JSON."""
|
||||||
|
|
||||||
|
def test_generates_env_with_request_fields(self):
|
||||||
|
request = {
|
||||||
|
"consumerRepo": "acdl/consumer-b",
|
||||||
|
"requestedEnvironment": "qa",
|
||||||
|
"ownerId": "team-b",
|
||||||
|
"billingTag": "cost-center-b",
|
||||||
|
}
|
||||||
|
env = generate_env_file(request, template_env="dev")
|
||||||
|
assert env["name"] == "qa"
|
||||||
|
assert env["ownerId"] == "team-b"
|
||||||
|
assert env["billingTag"] == "cost-center-b"
|
||||||
|
assert env["account_id"] == "000000000000" # placeholder
|
||||||
|
assert "consumer-b" in env["description"]
|
||||||
|
|
||||||
|
def test_preserves_template_network_and_state(self):
|
||||||
|
request = {
|
||||||
|
"consumerRepo": "acdl/c",
|
||||||
|
"requestedEnvironment": "prod",
|
||||||
|
"ownerId": "team-a",
|
||||||
|
"billingTag": "cc-a",
|
||||||
|
}
|
||||||
|
env = generate_env_file(request, template_env="dev")
|
||||||
|
assert "vpc_cidr" in env["network"]
|
||||||
|
assert "bucket" in env["state_backend"]
|
||||||
|
assert env["region"] == "us-east-1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnboardingRequestMessage:
|
||||||
|
"""P19 (REQ-183): the request message is self-service."""
|
||||||
|
|
||||||
|
def test_message_mentions_onboard_consumer(self):
|
||||||
|
msg = _onboarding_request_message("dev")
|
||||||
|
assert "onboard_consumer" in msg
|
||||||
|
assert "Nova" in msg
|
||||||
Reference in New Issue
Block a user