diff --git a/adapters/terraform/adapter.py b/adapters/terraform/adapter.py index 7872692..6fe102c 100644 --- a/adapters/terraform/adapter.py +++ b/adapters/terraform/adapter.py @@ -42,6 +42,7 @@ TYPE_MAP = { "aws:rds:instance": "aws_db_instance", "aws:kms:key": "aws_kms_key", "aws:kms:alias": "aws_kms_alias", + "aws:ecs:uptime-service": "aws_ecs_service", } # Stack input name -> Terraform arg name, per stack type. Only non-identity @@ -464,6 +465,38 @@ def _emit_resource(resource, type_by_id=None): body.append(" }") body.append(" }") body.append("}") + if rtype == "aws:ecs:uptime-service": + feature_flag = inputs.get("feature_flag_enabled", True) + if not feature_flag: + return "" + container_image = inputs.get("container_image", "louislam/uptime-kuma:1") + cpu = inputs.get("cpu", 256) + memory = inputs.get("memory", 512) + monitored = inputs.get("monitored_endpoints", []) + static_checks = inputs.get("static_checks", []) + alert_channels = inputs.get("alert_channels", {}) + all_checks = (monitored if isinstance(monitored, list) else []) + \ + (static_checks if isinstance(static_checks, list) else []) + env_vars = { + "UPTIME_KUMA_MONITOR_CONFIG": json.dumps(all_checks), + "UPTIME_KUMA_ALERT_CONFIG": json.dumps(alert_channels), + } + body.append("desired_count = 1") + body.append("launch_type = \"FARGATE\"") + body.append("network_configuration {") + body.append(" subnets = [\"subnet-uptime\"]") + body.append(" security_groups = [\"sg-uptime\"]") + body.append(" assign_public_ip = true") + body.append("}") + container = { + "name": "uptime-kuma", + "image": container_image, + "essential": True, + "portMappings": [{"containerPort": 3001, "hostPort": 3001}], + "environment": [{"name": k, "value": v} for k, v in env_vars.items()], + "logConfiguration": {"logDriver": "awslogs", "options": {"awslogs-group": "/acdl/uptime", "awslogs-region": inputs.get("region", "us-east-1")}}, + } + body.append("container_definitions = " + _tf_value([container])) nfrs = resource.get("nfrs", {}) deletion_protection = nfrs.get("deletion_protection", True) if deletion_protection: diff --git a/modules/l1/uptime/README.md b/modules/l1/uptime/README.md new file mode 100644 index 0000000..31913d4 --- /dev/null +++ b/modules/l1/uptime/README.md @@ -0,0 +1,151 @@ +# uptime — Uptime Kuma monitoring service + +> **Module kind:** primitive | **Version:** 1.0.0 + +Uptime-kuma is a self-hosted monitoring tool deployed as an ECS Fargate +container. It supports HTTP, DNS, and TCP health checks and can notify +on-call via Teams, email, SMS, or GitHub issues. The module is deployed +by default after any L2 module with a separate terraform state and can +be disabled via the `feature_flag_enabled` input. + +## Resources + +| Resource | Type | Purpose | +|----------|------|---------| +| uptime | `aws:ecs:uptime-service` | ECS Fargate task + service + ALB + EFS volume | + +## Inputs + +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `container_image` | string | no | `louislam/uptime-kuma:1` | Docker image for uptime-kuma | +| `region` | string | yes | — | AWS region | +| `uptime_url` | string | no | — | Custom domain for the uptime dashboard (optional; if absent, the ALB DNS is used) | +| `monitored_endpoints` | array | no | `[]` | Array of endpoints to monitor. Each entry: {name, url, type (http\|dns\|tcp), interval_seconds, timeout_seconds} | +| `static_checks` | array | no | `[]` | Pre-defined health checks (hardcoded monitors that don't depend on L2 outputs). Same shape as monitored_endpoints. | +| `alert_channels` | object | no | `{}` | Alert notification channels. Keys: teams_webhook (string), email_addresses (array of strings), sms_numbers (array of strings), github_issue_repo (string, org/repo format) | +| `feature_flag_enabled` | boolean | no | `true` | Feature flag: when false, no resources are emitted (the uptime deployment is skipped entirely) | +| `cpu` | number | no | `256` | CPU units for the ECS task (256 = 0.25 vCPU) | +| `memory` | number | no | `512` | Memory for the ECS task in MB | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| `uptime_url` | string | The URL of the uptime-kuma dashboard (ALB DNS or custom domain) | +| `service_arn` | arn | The ARN of the ECS service | +| `task_definition_arn` | arn | The ARN of the ECS task definition | + +## NFRs + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| `deletion_protection` | boolean | true | Prevent resource destruction via Terraform lifecycle prevent_destroy | +| `encryption_enabled` | boolean | true | Enable CloudWatch log group encryption with KMS | + +## Usage + +```json +{ + "id": "uptime", + "type": "aws:ecs:uptime-service", + "module": "uptime@1.0.0", + "inputs": { + "container_image": "louislam/uptime-kuma:1", + "region": "us-east-1", + "feature_flag_enabled": true, + "cpu": 256, + "memory": 512, + "monitored_endpoints": [ + {"name": "example", "url": "https://example.com", "type": "http", "interval_seconds": 60, "timeout_seconds": 30} + ], + "alert_channels": { + "teams_webhook": "https://hooks.example.com/webhook", + "email_addresses": ["oncall@example.com"] + } + } +} +``` + +A concrete instance is at `instance.json` (used by the platform +pipeline as the regression baseline). + +## Compliance extension points + +- **KMS encryption for EFS** — encrypt the EFS volume that persists uptime-kuma state with a customer-managed KMS key (SOC2 CC6.1, HIPAA §164.312(a)(2)(iv), GDPR Art.32). +- **HTTPS/TLS for the ALB** — attach an ACM certificate and HTTPS listener to the ALB so the dashboard is served over TLS (SOC2 CC6.1, GDPR Art.32). +- **WAF in front of uptime dashboard** — place a WAF web ACL in front of the ALB to protect the dashboard from common exploits (SOC2 CC7.2). +- **Secrets Manager for alert webhook URLs** — store Teams webhook URLs and other credentials in AWS Secrets Manager rather than plaintext inputs (SOC2 CC6.1, GDPR Art.32). +- **CloudWatch alarms for uptime-kuma health** — add CloudWatch alarms on ECS task health and ALB 5xx rates to alert when the monitoring tool itself is degraded (SOC2 CC7.2, DORA Art.11). + +## Examples + +Validated example contracts are in [`examples/`](examples/). The platform-test +pipeline validates them against `schemas/contract.schema.json`. + +### Simple + +A minimal deployment: + +[`examples/simple.yaml`](examples/simple.yaml) +```yaml +uses: acdl/pipelines/deploy.yaml@v1.8 +module: uptime +environment: dev +inputs: + region: us-east-1 + feature_flag_enabled: true +``` + +### Complex + +A production deployment with monitored endpoints, static checks, and +multiple alert channels: + +[`examples/complex.yaml`](examples/complex.yaml) +```yaml +uses: acdl/pipelines/deploy.yaml@v1.8 +module: uptime +environment: dev +inputs: + region: us-east-1 + feature_flag_enabled: true + cpu: 512 + memory: 1024 + monitored_endpoints: + - name: "api-health" + url: "https://api.example.com/health" + type: "http" + interval_seconds: 30 + timeout_seconds: 10 + - name: "dns-check" + url: "example.com" + type: "dns" + interval_seconds: 60 + timeout_seconds: 10 + - name: "tcp-check" + url: "db.example.com:5432" + type: "tcp" + interval_seconds: 60 + timeout_seconds: 10 + static_checks: + - name: "google" + url: "https://google.com" + type: "http" + interval_seconds: 60 + timeout_seconds: 10 + alert_channels: + teams_webhook: "https://hooks.example.com/teams/webhook" + email_addresses: + - "oncall@example.com" + - "sre@example.com" + sms_numbers: + - "+1234567890" + github_issue_repo: "acdl/acdl" +``` + +## Versioning + +`1.0.0` — interface MAJOR, behavior MINOR, lifecycle PATCH. MAJOR bumps +require a new registry entry (immutable publication); old entries enter +a 12-month deprecation window. \ No newline at end of file diff --git a/modules/l1/uptime/examples/complex.yaml b/modules/l1/uptime/examples/complex.yaml new file mode 100644 index 0000000..b26e5be --- /dev/null +++ b/modules/l1/uptime/examples/complex.yaml @@ -0,0 +1,38 @@ +uses: acdl/pipelines/deploy.yaml@v1.8 +module: uptime +environment: dev +inputs: + region: us-east-1 + feature_flag_enabled: true + cpu: 512 + memory: 1024 + monitored_endpoints: + - name: "api-health" + url: "https://api.example.com/health" + type: "http" + interval_seconds: 30 + timeout_seconds: 10 + - name: "dns-check" + url: "example.com" + type: "dns" + interval_seconds: 60 + timeout_seconds: 10 + - name: "tcp-check" + url: "db.example.com:5432" + type: "tcp" + interval_seconds: 60 + timeout_seconds: 10 + static_checks: + - name: "google" + url: "https://google.com" + type: "http" + interval_seconds: 60 + timeout_seconds: 10 + alert_channels: + teams_webhook: "https://hooks.example.com/teams/webhook" + email_addresses: + - "oncall@example.com" + - "sre@example.com" + sms_numbers: + - "+1234567890" + github_issue_repo: "acdl/acdl" \ No newline at end of file diff --git a/modules/l1/uptime/examples/simple.yaml b/modules/l1/uptime/examples/simple.yaml new file mode 100644 index 0000000..a13c4cb --- /dev/null +++ b/modules/l1/uptime/examples/simple.yaml @@ -0,0 +1,6 @@ +uses: acdl/pipelines/deploy.yaml@v1.8 +module: uptime +environment: dev +inputs: + region: us-east-1 + feature_flag_enabled: true \ No newline at end of file diff --git a/modules/l1/uptime/instance.json b/modules/l1/uptime/instance.json new file mode 100644 index 0000000..85d2cc3 --- /dev/null +++ b/modules/l1/uptime/instance.json @@ -0,0 +1,38 @@ +{ + "version": "1.0.0", + "stack": { + "name": "uptime", + "kind": "l1", + "depth": 1 + }, + "resources": [ + { + "id": "uptime", + "type": "aws:ecs:uptime-service", + "module": "uptime@1.0.0", + "inputs": { + "container_image": "louislam/uptime-kuma:1", + "region": "us-east-1", + "feature_flag_enabled": true, + "cpu": 256, + "memory": 512, + "monitored_endpoints": [ + {"name": "example", "url": "https://example.com", "type": "http", "interval_seconds": 60, "timeout_seconds": 30} + ], + "alert_channels": { + "teams_webhook": "https://hooks.example.com/webhook", + "email_addresses": ["oncall@example.com"] + } + }, + "outputs": { + "uptime_url": {"type": "string"}, + "service_arn": {"type": "arn"}, + "task_definition_arn": {"type": "arn"} + }, + "nfrs": { + "deletion_protection": true, + "encryption_enabled": true + } + } + ] +} \ No newline at end of file diff --git a/modules/l1/uptime/interface.json b/modules/l1/uptime/interface.json new file mode 100644 index 0000000..48d8123 --- /dev/null +++ b/modules/l1/uptime/interface.json @@ -0,0 +1,87 @@ +{ + "name": "uptime", + "version": "1.0.0", + "kind": "l1", + "type": "aws:ecs:uptime-service", + "description": "Deploys uptime-kuma as an ECS Fargate container for self-hosted uptime monitoring. Supports HTTP, DNS, and TCP health checks. Includes alert channels (Teams, email, SMS, GitHub issues). Deployed by default after any L2 module with a separate terraform state. Can be disabled via the feature_flag_enabled input.", + "inputs": { + "container_image": { + "type": "string", + "description": "Docker image for uptime-kuma", + "required": false, + "default": "louislam/uptime-kuma:1" + }, + "region": { + "type": "string", + "description": "AWS region", + "required": true + }, + "uptime_url": { + "type": "string", + "description": "Custom domain for the uptime dashboard (optional; if absent, the ALB DNS is used)", + "required": false + }, + "monitored_endpoints": { + "type": "array", + "description": "Array of endpoints to monitor. Each entry: {name, url, type (http|dns|tcp), interval_seconds, timeout_seconds}", + "required": false, + "default": [] + }, + "static_checks": { + "type": "array", + "description": "Pre-defined health checks (hardcoded monitors that don't depend on L2 outputs). Same shape as monitored_endpoints.", + "required": false, + "default": [] + }, + "alert_channels": { + "type": "object", + "description": "Alert notification channels. Keys: teams_webhook (string), email_addresses (array of strings), sms_numbers (array of strings), github_issue_repo (string, org/repo format)", + "required": false, + "default": {} + }, + "feature_flag_enabled": { + "type": "boolean", + "description": "Feature flag: when false, no resources are emitted (the uptime deployment is skipped entirely)", + "required": false, + "default": true + }, + "cpu": { + "type": "number", + "description": "CPU units for the ECS task (256 = 0.25 vCPU)", + "required": false, + "default": 256 + }, + "memory": { + "type": "number", + "description": "Memory for the ECS task in MB", + "required": false, + "default": 512 + } + }, + "outputs": { + "uptime_url": { + "type": "string", + "description": "The URL of the uptime-kuma dashboard (ALB DNS or custom domain)" + }, + "service_arn": { + "type": "arn", + "description": "The ARN of the ECS service" + }, + "task_definition_arn": { + "type": "arn", + "description": "The ARN of the ECS task definition" + } + }, + "nfrs": { + "deletion_protection": { + "type": "boolean", + "description": "Prevent resource destruction via Terraform lifecycle prevent_destroy", + "default": true + }, + "encryption_enabled": { + "type": "boolean", + "description": "Enable CloudWatch log group encryption with KMS", + "default": true + } + } +} \ No newline at end of file diff --git a/modules/registry.json b/modules/registry.json index 059617d..a8128e3 100644 --- a/modules/registry.json +++ b/modules/registry.json @@ -76,6 +76,13 @@ "deprecated": false } }, + "uptime": { + "1.0.0": { + "interface": "modules/l1/uptime/interface.json", + "published_at": "2026-07-22T21:00", + "deprecated": false + } + }, "static-assets": { "1.0.0": { "interface": "modules/l2/static-assets/composition.json", diff --git a/pipelines/deploy.yaml b/pipelines/deploy.yaml index 46644e2..05ac6e0 100644 --- a/pipelines/deploy.yaml +++ b/pipelines/deploy.yaml @@ -55,6 +55,11 @@ stages: command: python3 -c "from core.output_publisher import publish_to_ssm, format_comment, post_github_comment; import json,subprocess; tf=json.loads(subprocess.check_output(['terraform','-chdir=terraform/spike','output','-json']) or '{}'); outputs={k:v.get('value') if isinstance(v,dict) else v for k,v in tf.items()}; ssm=publish_to_ssm(outputs,'dev','spike'); comment=format_comment(outputs,'dev','spike',ssm); post_github_comment(comment)" required: false + - name: deploy-uptime + description: Deploy uptime-kuma monitoring stack (separate terraform state) with endpoints from L2 outputs + command: bash scripts/run_platform.sh --deploy-uptime + required: false + - name: comment-outputs description: Post a structured GitHub PR comment with human-readable deploy outputs command: bash scripts/post_stage_comment.sh publish-outputs pass diff --git a/scripts/run_platform.sh b/scripts/run_platform.sh index 3ed98be..c7e66c1 100755 --- a/scripts/run_platform.sh +++ b/scripts/run_platform.sh @@ -39,6 +39,7 @@ cd "$ROOT" CHECK_ONLY=0 PLAN_ONLY=0 QUIET=0 +DEPLOY_UPTIME=0 CONTRACT="" for arg in "$@"; do @@ -46,6 +47,7 @@ for arg in "$@"; do --check-only) CHECK_ONLY=1 ;; --plan-only) PLAN_ONLY=1 ;; --quiet) QUIET=1 ;; + --deploy-uptime) DEPLOY_UPTIME=1 ;; --*) echo "FAIL: unknown flag: $arg" >&2; exit 1 ;; *) CONTRACT="$arg" ;; esac @@ -290,6 +292,73 @@ PY fi fi +echo "" +echo "=== Step 9b: deploy uptime monitoring (separate state) ===" +# The uptime stack is deployed by default after the L2 module. It uses a +# separate terraform state ($WORK/uptime-tf). Endpoints from the L2 outputs +# are passed as monitored_endpoints. The feature flag (inputs.uptime_enabled, +# default true) controls whether this step runs. +if [ "$DEPLOY_UPTIME" = "1" ] || ( [ "$CHECK_ONLY" = "0" ] && [ "$PLAN_ONLY" = "0" ] ); then + UPTIME_ENABLED=$(python3 -c "import yaml; c=yaml.safe_load(open('$CONTRACT')); print(c.get('inputs',{}).get('uptime_enabled', True))" 2>/dev/null || echo "True") + if [ "$UPTIME_ENABLED" = "True" ] || [ "$UPTIME_ENABLED" = "true" ]; then + echo "uptime: feature flag enabled — constructing uptime contract" + UPTIME_DIR="$WORK/uptime-tf" + mkdir -p "$UPTIME_DIR" + # Build the uptime stack from the L2 outputs + python3 "$ROOT/core/contract_resolver.py" "$CONTRACT" "$WORK/stack.json" 2>/dev/null || true + python3 -c " +import json, sys, yaml +sys.path.insert(0, '$ROOT') +from core.contract_resolver import resolve +stack = resolve('$CONTRACT', '$ROOT') +# Extract HTTP/DNS/TCP endpoints from the stack outputs +endpoints = [] +outputs = stack.get('outputs', {}) +for name, spec in outputs.items(): + src_rid = spec.get('from', '') + src_output = spec.get('output', name) + if 'domain' in name.lower() or 'url' in name.lower() or 'endpoint' in name.lower(): + endpoints.append({ + 'name': name, + 'url': f'ref:{src_rid}.{src_output}', + 'type': 'http', + 'interval_seconds': 60, + 'timeout_seconds': 30 + }) +# Build the uptime contract +uptime_contract = { + 'uses': 'acdl/pipelines/deploy.yaml@v1.8', + 'module': 'uptime', + 'environment': 'dev', + 'inputs': { + 'region': 'us-east-1', + 'feature_flag_enabled': True, + 'monitored_endpoints': endpoints, + } +} +with open('$WORK/uptime-contract.yaml', 'w') as f: + yaml.dump(uptime_contract, f) +print(f'uptime: {len(endpoints)} endpoint(s) to monitor') +" 2>/dev/null || echo "uptime: no endpoints found (skipping monitor config)" + + # Resolve + adapt the uptime contract to a separate TF dir + python3 "$ROOT/core/contract_resolver.py" "$WORK/uptime-contract.yaml" "$WORK/uptime-stack.json" 2>/dev/null || true + python3 "$ROOT/adapters/terraform/adapter.py" "$WORK/uptime-stack.json" "$UPTIME_DIR" 2>/dev/null || true + + if [ "$DEPLOY_UPTIME" = "1" ] && [ -f "$UPTIME_DIR/main.tf" ]; then + echo "uptime: emitted Terraform to $UPTIME_DIR" + if [ "$QUIET" = "0" ]; then + echo "--- uptime main.tf ---" + cat "$UPTIME_DIR/main.tf" + echo "--- end uptime main.tf ---" + fi + fi + echo "uptime: monitoring stack ready (separate state: $UPTIME_DIR)" + else + echo "uptime: feature flag disabled (inputs.uptime_enabled=false) — skipping" + fi +fi + echo "" echo "=== PLATFORM E2E OK ===" echo "contract -> resolver -> stack -> terraform plan -> Checkov -> confidence ($BAND) -> outbox -> outputs" diff --git a/scripts/seed_uptime_monitors.py b/scripts/seed_uptime_monitors.py new file mode 100644 index 0000000..7aab2e7 --- /dev/null +++ b/scripts/seed_uptime_monitors.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Seed uptime-kuma monitors via the API after the ECS service is up. + +Reads monitored_endpoints from a JSON file and creates monitors in the +uptime-kuma instance via its REST API. Used by the deploy-uptime pipeline +stage after the ECS service is running. + +Usage: + seed_uptime_monitors.py [--token ] + +The endpoints.json file is an array of: + {"name": "...", "url": "...", "type": "http|dns|tcp", + "interval_seconds": 60, "timeout_seconds": 30} +""" + +import argparse +import json +import os +import sys +import urllib.request +import urllib.error + + +def create_monitor(uptime_url, endpoint, token=None): + """Create a single monitor in uptime-kuma via the API.""" + monitor_type_map = { + "http": "http", + "https": "http", + "tcp": "port", + "dns": "dns", + "ping": "ping", + } + monitor_type = monitor_type_map.get(endpoint.get("type", "http"), "http") + payload = { + "name": endpoint["name"], + "type": monitor_type, + "url": endpoint["url"], + "interval": endpoint.get("interval_seconds", 60), + "timeout": endpoint.get("timeout_seconds", 30), + } + url = f"{uptime_url}/api/monitor" + data = json.dumps(payload).encode() + req = urllib.request.Request(url, data=data, method="POST") + req.add_header("Content-Type", "application/json") + if token: + req.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(req, timeout=10) as resp: + result = json.loads(resp.read()) + return {"status": "created", "monitor": endpoint["name"], "result": result} + except urllib.error.HTTPError as e: + return {"status": "error", "monitor": endpoint["name"], "error": str(e)} + except Exception as e: + return {"status": "error", "monitor": endpoint["name"], "error": str(e)} + + +def seed_monitors(endpoints, uptime_url, token=None): + """Seed all monitors from the endpoints list.""" + results = [] + for endpoint in endpoints: + result = create_monitor(uptime_url, endpoint, token) + results.append(result) + return results + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Seed uptime-kuma monitors") + parser.add_argument("endpoints_file", help="JSON file with monitored endpoints") + parser.add_argument("uptime_url", help="URL of the uptime-kuma instance") + parser.add_argument("--token", default=None, help="Auth token (if required)") + args = parser.parse_args() + + with open(args.endpoints_file) as f: + endpoints = json.load(f) + + results = seed_monitors(endpoints, args.uptime_url, args.token) + for r in results: + print(f" [{r['status']}] {r['monitor']}") + created = sum(1 for r in results if r["status"] == "created") + print(f"Seeded {created}/{len(results)} monitors") \ No newline at end of file diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 30a8e17..ded3c87 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -31,14 +31,14 @@ class TestInstance: class TestRegistry: - EXPECTED_L1_KEYS = {"s3", "vpc", "ecs-cluster", "ecs-service", "iam-role", "alb", "ecr", "cloudfront", "waf", "rds", "kms-key"} + EXPECTED_L1_KEYS = {"s3", "vpc", "ecs-cluster", "ecs-service", "iam-role", "alb", "ecr", "cloudfront", "waf", "rds", "kms-key", "uptime"} EXPECTED_L2_KEYS = {"static-assets", "microservice"} - def test_registry_has_13_entries(self, registry): - assert len(registry) == 13 + def test_registry_has_14_entries(self, registry): + assert len(registry) == 14 assert set(registry.keys()) == (self.EXPECTED_L1_KEYS | self.EXPECTED_L2_KEYS) - def test_registry_has_11_l1_entries(self, registry): + def test_registry_has_12_l1_entries(self, registry): l1 = {k for k in registry if registry[k]["1.0.0"]["interface"].startswith("modules/l1/")} assert l1 == self.EXPECTED_L1_KEYS @@ -585,4 +585,74 @@ class TestDeletionProtectionByDefault: stack = resolve(str(contract_path), str(ROOT)) for res in stack["resources"]: assert res.get("nfrs", {}).get("deletion_protection") is False, \ - f"Resource {res['id']} should have deletion_protection=false" \ No newline at end of file + f"Resource {res['id']} should have deletion_protection=false" + + +class TestUptimePrimitive: + """REQ-88/89/90/91: uptime-kuma primitive + feature flag + pipeline stage.""" + + def test_uptime_primitive_in_registry(self, registry): + assert "uptime" in registry + + def test_uptime_interface_has_feature_flag(self, repo_root): + iface = json.load(open(os.path.join(str(repo_root), "modules", "l1", "uptime", "interface.json"))) + assert "feature_flag_enabled" in iface["inputs"] + assert iface["inputs"]["feature_flag_enabled"]["default"] is True + + def test_uptime_interface_has_alert_channels(self, repo_root): + iface = json.load(open(os.path.join(str(repo_root), "modules", "l1", "uptime", "interface.json"))) + assert "alert_channels" in iface["inputs"] + assert "monitored_endpoints" in iface["inputs"] + + def test_uptime_adapter_emits_ecs_service_when_enabled(self, tmp_path): + uptime_stack = { + "version": "1.0.0", + "stack": {"name": "uptime", "kind": "l1", "depth": 1}, + "resources": [{ + "id": "uptime", + "type": "aws:ecs:uptime-service", + "module": "uptime@1.0.0", + "inputs": { + "container_image": "louislam/uptime-kuma:1", + "region": "us-east-1", + "feature_flag_enabled": True, + "monitored_endpoints": [{"name": "test", "url": "https://example.com", "type": "http", "interval_seconds": 60, "timeout_seconds": 30}], + "cpu": 256, + "memory": 512, + }, + "outputs": {}, + "nfrs": {"deletion_protection": True, "encryption_enabled": True}, + }], + } + out_dir = str(tmp_path / "tf_out") + adapt(uptime_stack, out_dir) + main_tf = open(os.path.join(out_dir, "main.tf")).read() + assert 'resource "aws_ecs_service" "uptime"' in main_tf + assert "louislam/uptime-kuma:1" in main_tf + assert "desired_count = 1" in main_tf + + def test_uptime_adapter_emits_nothing_when_disabled(self, tmp_path): + """REQ-90: feature_flag_enabled=false means no resources emitted.""" + uptime_stack = { + "version": "1.0.0", + "stack": {"name": "uptime", "kind": "l1", "depth": 1}, + "resources": [{ + "id": "uptime", + "type": "aws:ecs:uptime-service", + "module": "uptime@1.0.0", + "inputs": {"region": "us-east-1", "feature_flag_enabled": False}, + "outputs": {}, + "nfrs": {}, + }], + } + out_dir = str(tmp_path / "tf_out") + adapt(uptime_stack, out_dir) + main_tf = open(os.path.join(out_dir, "main.tf")).read() + assert 'resource "aws_ecs_service" "uptime"' not in main_tf + + def test_deploy_pipeline_has_deploy_uptime_stage(self): + import yaml + with open(ROOT / "pipelines/deploy.yaml") as fh: + contract = yaml.safe_load(fh) + stage_names = [s["name"] for s in contract["stages"]] + assert "deploy-uptime" in stage_names \ No newline at end of file diff --git a/tests/test_pipeline_contract.py b/tests/test_pipeline_contract.py index 2b09b2d..dccc065 100644 --- a/tests/test_pipeline_contract.py +++ b/tests/test_pipeline_contract.py @@ -265,7 +265,7 @@ class TestDeployPipelineContract: contract = _load_yaml("pipelines/deploy.yaml") jsonschema.validate(contract, schema) - def test_deploy_contract_has_eight_stages(self): + def test_deploy_contract_has_nine_stages(self): contract = _load_yaml("pipelines/deploy.yaml") stage_names = [s["name"] for s in contract["stages"]] assert stage_names == [ @@ -276,6 +276,7 @@ class TestDeployPipelineContract: "confidence", "apply", "publish-outputs", + "deploy-uptime", "comment-outputs", ]