diff --git a/core/environment_check.py b/core/environment_check.py index 98f30b5..532e751 100644 --- a/core/environment_check.py +++ b/core/environment_check.py @@ -55,6 +55,8 @@ def load(env_name, root=None): 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 ( "=== Nova Environment Onboarding ===\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" " authorization (ABAC)\n\n" "You do not provide an AWS account, VPC, subnet, or state bucket.\n\n" - "To request an environment:\n" - " 1. Contact the platform team with your repo name + the\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 team provisions the account/network/state/role\n" - " and binds the environment to your repo.\n" - " 3. Your next pipeline run will proceed normally.\n\n" - "Expected turnaround: contact the platform team for current SLA.\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" ) diff --git a/core/environments/README.md b/core/environments/README.md index c848cc3..5282a7f 100644 --- a/core/environments/README.md +++ b/core/environments/README.md @@ -33,5 +33,13 @@ halting the pipeline before any work is done. A new environment is a platform-team action: provision the AWS account / network / state backend / IAM role, then add a `.json` here and bind -it to the consumer repo. Self-service environment provisioning is on the -roadmap; today it is a platform-team action. \ No newline at end of file +it to the consumer repo. + +**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 `.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). \ No newline at end of file diff --git a/core/onboarding.py b/core/onboarding.py new file mode 100644 index 0000000..bb41e1a --- /dev/null +++ b/core/onboarding.py @@ -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 ``.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 [--out ] + 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()) \ No newline at end of file diff --git a/tests/test_environment_check.py b/tests/test_environment_check.py index 296171c..a4bd99d 100644 --- a/tests/test_environment_check.py +++ b/tests/test_environment_check.py @@ -21,7 +21,10 @@ class TestEnvironmentCheck: assert ok is False assert "nonexistent-env" 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): msg = _onboarding_message("qa") @@ -102,4 +105,16 @@ class TestRunPlatformWireIn: ) assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" assert "PLATFORM CHECK OK" in result.stdout - assert "environment" in result.stdout.lower() or "Step 0" in result.stdout \ No newline at end of file + 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 diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py new file mode 100644 index 0000000..cb943f4 --- /dev/null +++ b/tests/test_onboarding.py @@ -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 \ No newline at end of file