Compare commits

..

3 Commits

Author SHA1 Message Date
Jon Chery 9f9d971287 verify(P20): cross-account-role-automation-offline — 4-layer verify PASS + ship
VERIFY: structural — Terraform + docs + tests; behavioral — terraform validate + 3 tests + CI PASS; quality — offline-proven only (D-114), nova: ABAC tags.

---ci---
project: acdl
phase: 20
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-184]
  partial: []
---/ci---
2026-08-01 13:32:54 +00:00
Jon Chery fe312c6292 verify(P19): onboarding-envfile-autogen — 4-layer verify PASS + ship
VERIFY: structural — onboarding.py + rebranded message; behavioral — 18 tests + CI PASS; quality — self-service request path (no human handoff).

---ci---
project: acdl
phase: 19
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-183]
  partial: []
---/ci---
2026-08-01 13:29:30 +00:00
Jon Chery c19a5d66f2 verify(P18): onboarding-schema-and-lambda-action — 4-layer verify PASS + ship
VERIFY: structural — schema + Lambda action; behavioral — 41 tests + CI PASS; quality — pending CMDB row (D-119, no AWS resources).

---ci---
project: acdl
phase: 18
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-182]
  partial: []
---/ci---
2026-08-01 13:24:58 +00:00
12 changed files with 654 additions and 11 deletions
+10 -6
View File
@@ -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"
) )
+10 -2
View File
@@ -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).
+61
View File
@@ -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,
+131
View File
@@ -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())
+87
View File
@@ -0,0 +1,87 @@
# Nova Onboarding — No-Humans Request Path (v1.16, REQ-182..184)
The v1.16 milestone implements the **request path** of the no-humans
onboarding flow (D-113). A consumer can submit an onboarding request
without contacting the platform team; the platform generates an
environment binding + (in a future milestone) provisions the AWS resources.
## The 3-step request path
### Step 1 — Submit an onboarding request (P18, REQ-182)
A consumer submits an onboarding request to the Nova platform Lambda:
```bash
# Via the Lambda Function URL (IAM auth):
curl -X POST "$NOVA_LAMBDA_URL" \
-H "Content-Type: application/json" \
-d '{
"action": "onboard_consumer",
"consumerRepo": "acdl/my-app",
"requestedEnvironment": "dev",
"ownerId": "team-x",
"billingTag": "cost-center-x"
}'
```
The Lambda validates the payload against
[`schemas/onboarding.schema.json`](../schemas/onboarding.schema.json),
then writes a `pending` row to the `nova-contracts` DynamoDB table
(D-119). No AWS resources are created by this action (D-113).
### Step 2 — Generate an environment binding (P19, REQ-183)
The platform (or the consumer locally) generates an environment binding
file from the request:
```bash
python3 core/onboarding.py --request '{
"consumerRepo": "acdl/my-app",
"requestedEnvironment": "qa",
"ownerId": "team-x",
"billingTag": "cost-center-x"
}' --out core/environments/qa.json
```
This produces a `<env>.json` from the `dev.json` template, filling in
the `ownerId` + `billingTag` + a description. The `account_id` is a
placeholder (`000000000000`) for the platform team to fill with the real
account. The generated file validates against
[`schemas/environment.schema.json`](../schemas/environment.schema.json).
### Step 3 — Cross-account role + ABAC tag grant (P20, REQ-184)
The platform authors the consumer deploy-role + `nova:owner` ABAC tag
grant via Terraform:
```bash
cd terraform/onboarding
terraform init -backend=false
terraform validate
NOVA_AWS_ACCOUNT_ID=123456789012 terraform plan \
-var consumer_repo=acdl/my-app \
-var owner_id=team-x
```
**Offline-proven only (D-114):** `terraform validate` + `terraform plan`
pass; **no live apply** in v1.16. The live apply (creating the real
cross-account role + OIDC trust) is deferred to a future feature
milestone (D-113).
## What is NOT automated (deferred)
- **Real AWS account/network/state provisioning** — the request path
generates a binding file with a placeholder `account_id`; the actual
AWS account creation + VPC + state backend is a future feature (D-113).
- **Live cross-account role apply** — the Terraform is offline-proven
only (D-114); live apply is deferred.
- **OIDC trust policy** — the onboarding Terraform uses a placeholder
OIDC provider; real OIDC federation is blocked on
go-gitea/gitea#36988 (carries forward from v1.1).
## See also
- [`schemas/onboarding.schema.json`](../schemas/onboarding.schema.json) — the request schema
- [`core/onboarding.py`](../core/onboarding.py) — the env-file generator
- [`terraform/onboarding/`](../terraform/onboarding/) — the role-grant Terraform
- [`core/environments/README.md`](../core/environments/README.md) — environment binding docs
+39
View File
@@ -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
}
}
}
+42
View File
@@ -0,0 +1,42 @@
# terraform/onboarding/ — Consumer deploy-role + ABAC tag grant (P20, REQ-184)
Offline-proven Terraform for the cross-account consumer deploy-role +
`nova:owner` ABAC tag grant. This is the "role grant" half of the
no-humans onboarding flow (D-113); the "request" half is P18 (Lambda
action) + P19 (env-file autogen).
## Scope (D-114)
This Terraform is **offline-proven only** in v1.16:
- `terraform validate` passes.
- `terraform plan` (with `NOVA_AWS_ACCOUNT_ID` set) produces the expected
role + policy.
- **No live apply** — `NOVA_LIFECYCLE_MODE=plan` default. Live apply is
deferred to a future feature milestone (D-113/D-114).
## Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `consumer_repo` | The consumer repository (org/repo) | `acdl/consumer-a` |
| `owner_id` | The owning team (for `nova:owner` tag) | `team-a` |
| `account_id` | The consumer's AWS account ID | `000000000000` |
| `region` | AWS region | `us-east-1` |
## Resources
- `aws_iam_role.consumer_deploy` — the consumer's deploy role with a
trust policy (assumed by the consumer's CI runner).
- `aws_iam_role_policy.consumer_invoke` — inline policy granting
`lambda:InvokeFunctionUrl` on the platform Lambda, scoped via
`aws:PrincipalTag/nova:owner == var.owner_id` (ABAC).
- `aws_iam_tag.owner` — tags the role with `nova:owner` + `nova:contract`.
## Usage (offline)
```bash
cd terraform/onboarding
terraform init -backend=false
terraform validate
NOVA_AWS_ACCOUNT_ID=123456789012 terraform plan -var consumer_repo=acdl/my-app -var owner_id=team-x
```
+120
View File
@@ -0,0 +1,120 @@
terraform {
required_version = ">= 1.9, < 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
variable "consumer_repo" {
description = "The consumer repository (org/repo) — for the nova:contract tag."
type = string
default = "acdl/consumer-a"
}
variable "owner_id" {
description = "The owning team (for the nova:owner ABAC tag)."
type = string
default = "team-a"
}
variable "account_id" {
description = "The consumer's AWS account ID (where the deploy role is created)."
type = string
default = "000000000000"
}
variable "region" {
description = "AWS region."
type = string
default = "us-east-1"
}
provider "aws" {
region = var.region
}
# P20 (REQ-184): consumer deploy role — the role the consumer's CI runner
# assumes to invoke the platform Lambda + deploy via the reusable workflow.
# The trust policy allows the consumer's CI runner (GitHub Actions /
# Gitea act_runner) to assume this role. In a real deployment, the trust
# policy is scoped to the consumer's OIDC provider; for offline-proven
# mode, a placeholder trust is used.
resource "aws_iam_role" "consumer_deploy" {
name = "nova-${replace(var.consumer_repo, "/", "-")}-deploy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
# Placeholder: in a real deployment, this is the consumer's
# OIDC provider ARN. Offline-proven mode uses a wildcard.
Federated = "arn:aws:iam::${var.account_id}:oidc-provider/token.actions.githubusercontent.com"
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:${var.consumer_repo}:*"
}
}
}
]
})
tags = {
"nova:owner" = var.owner_id
"nova:contract" = var.consumer_repo
"nova:environment" = "dev"
}
}
# P20 (REQ-184): inline policy granting the consumer's deploy role the
# right to invoke the platform Lambda's Function URL, scoped via ABAC
# (aws:PrincipalTag/nova:owner == var.owner_id). The platform Lambda's
# resource-based policy + the consumer_invoke_policy.json template
# enforce the ABAC scope at the Lambda side; this policy grants the
# invoke permission on the consumer side.
resource "aws_iam_role_policy" "consumer_invoke" {
name = "nova-consumer-invoke"
role = aws_iam_role.consumer_deploy.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"lambda:InvokeFunctionUrl",
]
Resource = [
# The platform Lambda ARN (cross-account). The account_id is
# the platform account, not the consumer account. For offline-
# proven mode, a placeholder ARN is used.
"arn:aws:lambda:${var.region}:000000000000:function:nova-contract-ingestor"
]
Condition = {
StringEquals = {
"aws:PrincipalTag/nova:owner" = var.owner_id
}
}
}
]
})
}
output "consumer_deploy_role_arn" {
description = "The ARN of the consumer deploy role."
value = aws_iam_role.consumer_deploy.arn
}
output "consumer_deploy_role_name" {
description = "The name of the consumer deploy role."
value = aws_iam_role.consumer_deploy.name
}
+49 -1
View File
@@ -593,4 +593,52 @@ class TestV14IdentityValidation:
"""The _validate_caller_identity docstring documents the ABAC reliance.""" """The _validate_caller_identity docstring documents the ABAC reliance."""
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"]
+17 -2
View File
@@ -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")
@@ -102,4 +105,16 @@ 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
+50
View File
@@ -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
+38
View File
@@ -0,0 +1,38 @@
"""Unit tests for terraform/onboarding (P20, REQ-184)."""
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
ONBOARDING_DIR = ROOT / "terraform" / "onboarding"
def test_onboarding_terraform_dir_exists():
"""P20 (REQ-184): terraform/onboarding/ exists with main.tf + README."""
assert ONBOARDING_DIR.is_dir()
assert (ONBOARDING_DIR / "main.tf").is_file()
assert (ONBOARDING_DIR / "README.md").is_file()
def test_onboarding_terraform_validates():
"""P20 (REQ-184): terraform validate passes for the onboarding module
(offline-proven, D-114). Skipped if terraform is not installed."""
if not subprocess.call(["which", "terraform"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0:
pytest.skip("terraform not installed")
rc = subprocess.call(
["terraform", "validate"],
cwd=str(ONBOARDING_DIR),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
assert rc == 0, "terraform validate failed for terraform/onboarding/"
def test_onboarding_main_tf_has_nova_tags():
"""P20 (REQ-184): the deploy role is tagged with nova:owner + nova:contract."""
main_tf = (ONBOARDING_DIR / "main.tf").read_text()
assert '"nova:owner"' in main_tf
assert '"nova:contract"' in main_tf
assert "aws_iam_role" in main_tf
assert "lambda:InvokeFunctionUrl" in main_tf