diff --git a/.github/workflows/commit-terraform.yml b/.github/workflows/commit-terraform.yml index a0e1611..875c19c 100644 --- a/.github/workflows/commit-terraform.yml +++ b/.github/workflows/commit-terraform.yml @@ -60,14 +60,15 @@ jobs: sparse-checkout: scripts persist-credentials: false - - name: Generate Terraform from app.yaml + - name: Expand modules from app.yaml if: steps.check.outputs.has_yaml == 'true' env: APP_SERVICE: ${{ github.event.repository.name }} AWS_ACCOUNT_ID: ${{ inputs.aws_account_id }} AWS_REGION: ${{ inputs.aws_region }} TF_ROOT: ${{ inputs.tf_root }} - run: sh .platform/scripts/generate-terraform.sh + PLATFORM_ROOT: .platform + run: python3 .platform/scripts/expand-modules.py - name: Commit and push generated files if: steps.check.outputs.has_yaml == 'true' diff --git a/.github/workflows/expand-terraform.yml b/.github/workflows/expand-terraform.yml new file mode 100644 index 0000000..9e8610f --- /dev/null +++ b/.github/workflows/expand-terraform.yml @@ -0,0 +1,62 @@ +name: Expand Terraform + +# Expands app.yaml into raw Terraform resource definitions using the module +# expander. Commits the generated .tf files back to the repo so that +# tf-plan and tf-apply work on committed files — no repeated expansion. + +on: + workflow_call: + inputs: + aws_account_id: + description: "AWS account ID" + type: string + default: "553637109631" + aws_region: + description: "AWS region" + type: string + default: "eu-central-1" + tf_root: + description: "Terraform root directory" + type: string + default: "terraform" + +permissions: + contents: write + +jobs: + expand: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.PLATFORM_APP_ID }} + private-key: ${{ secrets.PLATFORM_APP_PRIVATE_KEY }} + owner: javaBin + + - name: Checkout platform scripts + uses: actions/checkout@v4 + with: + repository: javaBin/platform + token: ${{ steps.app-token.outputs.token }} + path: .platform + sparse-checkout: | + scripts + terraform/modules + + - name: Expand modules from app.yaml + env: + APP_SERVICE: ${{ github.event.repository.name }} + AWS_ACCOUNT_ID: ${{ inputs.aws_account_id }} + AWS_REGION: ${{ inputs.aws_region }} + TF_ROOT: ${{ inputs.tf_root }} + PLATFORM_ROOT: .platform + run: python3 .platform/scripts/expand-modules.py + + - name: Commit and push generated files + run: sh .platform/scripts/commit-generated-tf.sh "${{ inputs.tf_root }}" "${{ github.ref_name }}" diff --git a/.github/workflows/javabin.yml b/.github/workflows/javabin.yml index 5aae4c8..5d7d062 100644 --- a/.github/workflows/javabin.yml +++ b/.github/workflows/javabin.yml @@ -58,11 +58,23 @@ jobs: secrets: inherit # -------------------------------------------------------------------------- - # 4. Terraform plan (if terraform/ exists or app.yaml needs generation) + # 4. Expand app.yaml → Terraform files (commit to repo, run once) # -------------------------------------------------------------------------- - tf-plan: + expand: needs: detect - if: needs.detect.outputs.has_tf == 'true' || needs.detect.outputs.has_yaml == 'true' + if: needs.detect.outputs.has_yaml == 'true' + uses: javaBin/platform/.github/workflows/expand-terraform.yml@main + secrets: inherit + + # -------------------------------------------------------------------------- + # 5. Terraform plan (after expand commits TF files, or if terraform/ already exists) + # -------------------------------------------------------------------------- + tf-plan: + needs: [detect, expand] + if: | + always() && + (needs.detect.outputs.has_tf == 'true' || needs.detect.outputs.has_yaml == 'true') && + (needs.expand.result == 'success' || needs.expand.result == 'skipped') uses: javaBin/platform/.github/workflows/tf-plan.yml@main secrets: inherit @@ -108,17 +120,7 @@ jobs: image_tag: ${{ needs.docker-build.outputs.image_tag }} secrets: inherit - # -------------------------------------------------------------------------- - # 8. Commit generated Terraform back to app repo - # -------------------------------------------------------------------------- - commit-terraform: - needs: [detect, tf-apply] - if: >- - github.ref == 'refs/heads/main' && - needs.detect.outputs.has_yaml == 'true' && - needs.tf-apply.result == 'success' - uses: javaBin/platform/.github/workflows/commit-terraform.yml@main - secrets: inherit + # (commit-terraform removed — expand-terraform.yml handles it) # -------------------------------------------------------------------------- # 9. EB deploy (transitional — for repos with .elasticbeanstalk/) diff --git a/.github/workflows/tf-apply.yml b/.github/workflows/tf-apply.yml index f59ea8f..14b27f9 100644 --- a/.github/workflows/tf-apply.yml +++ b/.github/workflows/tf-apply.yml @@ -71,19 +71,6 @@ jobs: - name: Check risk level run: sh .platform/scripts/check-risk-gate.sh "${{ inputs.risk_level }}" "${{ github.repository }}" "${{ github.sha }}" /javabin/slack/platform-override-alerts-webhook - - name: Generate Terraform from app.yaml - if: hashFiles('app.yaml') != '' - env: - APP_SERVICE: ${{ github.event.repository.name }} - AWS_ACCOUNT_ID: ${{ inputs.aws_account_id }} - AWS_REGION: ${{ inputs.aws_region }} - TF_ROOT: ${{ inputs.tf_root }} - run: sh .platform/scripts/generate-terraform.sh - - - name: Configure git credentials for module downloads - if: hashFiles('app.yaml') != '' - run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" - - name: Download plan from S3 working-directory: ${{ inputs.tf_root }} run: aws s3 cp "s3://${PLAN_BUCKET}/${{ inputs.plan_key }}" tfplan diff --git a/.github/workflows/tf-plan.yml b/.github/workflows/tf-plan.yml index 5e24654..3fab0d8 100644 --- a/.github/workflows/tf-plan.yml +++ b/.github/workflows/tf-plan.yml @@ -70,19 +70,6 @@ jobs: path: .platform sparse-checkout: scripts - - name: Generate Terraform from app.yaml - if: hashFiles('app.yaml') != '' - env: - APP_SERVICE: ${{ github.event.repository.name }} - AWS_ACCOUNT_ID: ${{ inputs.aws_account_id }} - AWS_REGION: ${{ inputs.aws_region }} - TF_ROOT: ${{ inputs.tf_root }} - run: sh .platform/scripts/generate-terraform.sh - - - name: Configure git credentials for module downloads - if: hashFiles('app.yaml') != '' - run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" - - name: Terraform Init working-directory: ${{ inputs.tf_root }} run: terraform init -input=false diff --git a/.gitignore b/.gitignore index ecaec63..d023157 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ *.zip .DS_Store phases/ +scripts/__pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md index 051913c..202d620 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,7 +151,7 @@ terraform/state/ | `terraform/modules/service-secret/` | Secrets Manager secret with IAM policy output | | `terraform/modules/service-queue/` | SQS queue + DLQ with IAM policy output | | `terraform/modules/service-alarm/` | CloudWatch alarms for ECS service | -| `terraform/modules/app-stack/` | Golden path — reads `app.yaml`, creates all infra | +| ~~`terraform/modules/app-stack/`~~ | Removed — replaced by `scripts/expand-modules.py` + `scripts/registry.py` | | `terraform/modules/cognito-app-client/` | Cognito app client registration (code exists, no pools deployed yet) | ### GitHub Actions Workflows @@ -187,7 +187,8 @@ terraform/state/ | Script | What | |--------|------| | `scripts/bootstrap.sh` | One-time: create state bucket + lock tables | -| `scripts/generate-terraform.sh` | CI: app.yaml → Terraform files | +| `scripts/expand-modules.py` | CI: reads app.yaml + module sources, generates expanded .tf files | +| `scripts/registry.py` | Module registry — maps app.yaml sections to platform modules | | `scripts/provision-teams.py` | CI: fetch team YAMLs from registry, invoke team-provisioner Lambda | | `scripts/review-plan.py` | CI: LLM plan review via Bedrock | | `scripts/notify-slack.py` | CI: generic Slack webhook notification | @@ -268,11 +269,11 @@ The SA JSON key is at `/javabin/platform/google-admin-sa`, the impersonation tar | 2c | IAM / OIDC | **Deployed** — 5 CI roles (infra, per-app, deploy, override-approver, registry) | | 2d | Compute | **Deployed** — ECS cluster + ECR repos | | 2e | Monitoring | **Deployed** — GuardDuty, Security Hub, Config, SNS | -| 2f | Lambda Functions | **Deployed** — 6 working (Google/GitHub/Budget sync live, Cognito/IdC sync not yet implemented in Lambda) | +| 2f | Lambda Functions | **Deployed** — 6 working (Google/GitHub/Budget/Cognito/Identity Center sync live) | | 2g | Platform CI | **Done** — plan → LLM review → apply pipeline working | | 3a | Reusable Terraform Modules | **Code done** — 12 modules in repo | | 3b | GitHub Actions Workflows | **Code done** — 14 reusable workflows | -| 3c | app.yaml Schema + Generation | **Done** — generate-terraform.sh + docs | +| 3c | app.yaml Schema + Generation | **Done** — expand-modules.py + registry.py (expanded raw resources) | | 3d | Registry Repo | **Working** — repo exists, dispatch uses GitHub App token, team provisioner invoked | | 3e | javabin CLI | **Code done** — 4 commands (register, init, status, whoami) in javaBin/javabin-cli | | 3f | CI Images + Supporting Repos | Not started | diff --git a/scripts/expand-modules.py b/scripts/expand-modules.py index 401b54d..a8d015c 100644 --- a/scripts/expand-modules.py +++ b/scripts/expand-modules.py @@ -19,11 +19,11 @@ Optional env vars: PLATFORM_ROOT (defaults to .platform) """ +import json import os import re import subprocess import sys -import yaml # Import registry from the same directory sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -40,8 +40,20 @@ # --------------------------------------------------------------------------- def load_yaml(path): - with open(path) as f: - return yaml.safe_load(f) + """Load YAML via yq (converts to JSON) — no pyyaml dependency.""" + result = subprocess.run( + ["yq", "-o", "json", path], + capture_output=True, text=True, + ) + if result.returncode != 0: + # Fallback: try python yaml module if available + try: + import yaml + with open(path) as f: + return yaml.safe_load(f) + except ImportError: + raise RuntimeError(f"Failed to parse {path}: yq not found and pyyaml not installed") + return json.loads(result.stdout) def yaml_get(data, dot_path, default=None): diff --git a/scripts/generate-terraform.sh b/scripts/generate-terraform.sh deleted file mode 100755 index 7e14a7b..0000000 --- a/scripts/generate-terraform.sh +++ /dev/null @@ -1,206 +0,0 @@ -#!/bin/sh -# Generate Terraform files from app.yaml -# -# Generates backend.tf, providers.tf, main.tf into $TF_ROOT. -# Only overwrites files that have the GENERATED marker (or don't exist yet). -# Files without the marker are user-managed and left untouched. -# -# Single-env (no environments: key): -# Generates files in $TF_ROOT/ (current behavior) -# -# Multi-env (environments: key present): -# Generates files in $TF_ROOT/{env}/ for each environment -# Prod environment creates the ECR repo; others reference it via data source -# -# Required env vars: APP_SERVICE, AWS_ACCOUNT_ID, AWS_REGION, TF_ROOT - -set -e - -APP_YAML="app.yaml" -MARKER="# GENERATED FROM app.yaml — do not edit, changes will be overwritten" - -if [ ! -f "$APP_YAML" ]; then - echo "No app.yaml found, skipping generation" - exit 0 -fi - -echo "Generating Terraform from $APP_YAML" -mkdir -p "$TF_ROOT" - -# Only overwrite a file if it doesn't exist or has the GENERATED marker -should_write() { - local file="$1" - [ ! -f "$file" ] || head -1 "$file" | grep -qF "$MARKER" 2>/dev/null -} - -# Extract a top-level scalar value from app.yaml (simple grep, no yq dependency) -yaml_val() { - grep -m1 "^${1}:" "$APP_YAML" | sed "s/^${1}:[[:space:]]*//" | sed 's/[[:space:]]*#.*//' | tr -d '"' | tr -d "'" -} - -# List environment names from the environments: block -list_environments() { - awk ' - /^environments:/ { in_envs=1; next } - in_envs && /^[^ ]/ { exit } - in_envs && /^ [a-z][a-z0-9]*:/ { sub(/:.*/, ""); sub(/^ /, ""); print } - ' "$APP_YAML" -} - -APP_NAME=$(yaml_val name) -APP_TEAM=$(yaml_val team) -APP_AUTH=$(yaml_val auth) - -if [ -z "$APP_NAME" ]; then - echo "ERROR: app.yaml must have a 'name' field" - exit 1 -fi - -# Detect multi-environment mode -ENVIRONMENTS=$(list_environments) - -# Generate Terraform files for a single environment directory -# Args: $1=output_dir $2=state_key_suffix $3=env_name $4=env_tag $5=create_ecr $6=config_path -generate_env() { - local out_dir="$1" - local state_suffix="$2" - local env_name="$3" - local env_tag="$4" - local create_ecr="$5" - local config_path="$6" - - mkdir -p "$out_dir" - - # --- backend.tf --- - if should_write "$out_dir/backend.tf"; then - cat > "$out_dir/backend.tf" << EOF -${MARKER} -terraform { - backend "s3" { - bucket = "javabin-terraform-state-${AWS_ACCOUNT_ID}" - key = "apps/${APP_SERVICE}${state_suffix}/terraform.tfstate" - region = "${AWS_REGION}" - dynamodb_table = "javabin-terraform-app-locks" - encrypt = true - } -} -EOF - echo " wrote ${out_dir}/backend.tf" - fi - - # --- providers.tf --- - if should_write "$out_dir/providers.tf"; then - cat > "$out_dir/providers.tf" << EOF -${MARKER} -terraform { - required_version = ">= 1.5" - - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.0" - } - } -} - -provider "aws" { - region = "${AWS_REGION}" - - default_tags { - tags = { - project = "javabin" - managed-by = "terraform" - service = "${APP_SERVICE}" - team = "${APP_TEAM:-unknown}" - environment = "${env_tag}" - } - } -} -EOF - echo " wrote ${out_dir}/providers.tf" - fi - - # --- main.tf --- - if should_write "$out_dir/main.tf"; then - { - echo "${MARKER}" - echo 'module "app" {' - echo ' source = "git::https://github.com/javaBin/platform.git//terraform/modules/app-stack?ref=main"' - echo '' - echo " config_file = \"${config_path}\"" - echo " aws_account_id = \"${AWS_ACCOUNT_ID}\"" - if [ -n "$env_name" ]; then - echo " environment_name = \"${env_name}\"" - fi - if [ "$create_ecr" = "false" ]; then - echo " create_ecr = false" - fi - echo '}' - } > "$out_dir/main.tf" - echo " wrote ${out_dir}/main.tf" - fi -} - -if [ -n "$ENVIRONMENTS" ]; then - # Multi-environment mode - echo " detected environments: $ENVIRONMENTS" - - # Determine which env creates ECR (prod if it exists, otherwise first) - ECR_ENV="" - for env in $ENVIRONMENTS; do - if [ "$env" = "prod" ]; then - ECR_ENV="prod" - break - fi - done - if [ -z "$ECR_ENV" ]; then - ECR_ENV=$(echo "$ENVIRONMENTS" | head -1) - fi - - for env in $ENVIRONMENTS; do - create_ecr="false" - if [ "$env" = "$ECR_ENV" ]; then - create_ecr="true" - fi - - env_tag="$env" - if [ "$env" = "prod" ]; then - env_tag="production" - fi - - generate_env \ - "$TF_ROOT/$env" \ - "-${env}" \ - "$env" \ - "$env_tag" \ - "$create_ecr" \ - '${path.root}/../../app.yaml' - done -else - # Single-environment mode (backwards compatible) - generate_env \ - "$TF_ROOT" \ - "" \ - "" \ - "production" \ - "true" \ - '${path.root}/../app.yaml' -fi - -# Write a hash of app.yaml so terraform/ always has a git diff when YAML changes. -# Without this, YAML-only changes (e.g. adding a bucket) don't change the committed -# TF files, so the terraform track's `changes: terraform/**/*` rule never triggers. -if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$APP_YAML" | cut -d' ' -f1 > "$TF_ROOT/.app-yaml-hash" -else - shasum -a 256 "$APP_YAML" | cut -d' ' -f1 > "$TF_ROOT/.app-yaml-hash" -fi - -# Format generated files -if [ -n "$ENVIRONMENTS" ]; then - for env in $ENVIRONMENTS; do - terraform fmt "$TF_ROOT/$env" > /dev/null || true - done -else - terraform fmt "$TF_ROOT" > /dev/null || true -fi diff --git a/terraform/modules/app-stack/main.tf b/terraform/modules/app-stack/main.tf deleted file mode 100644 index 05244f2..0000000 --- a/terraform/modules/app-stack/main.tf +++ /dev/null @@ -1,294 +0,0 @@ -################################################################################ -# YAML Parsing + Defaults -################################################################################ - -locals { - raw = yamldecode(file(var.config_file)) - - # Top-level (name validated: lowercase alphanumeric + hyphens, max 20 chars) - name = regex("^[a-z][a-z0-9-]{0,19}$", local.raw.name) - env_suffix = var.environment_name != "" ? "-${var.environment_name}" : "" - qualified_name = "${local.name}${local.env_suffix}" - - # Environment-specific overrides (empty when not using environments) - env_config = var.environment_name != "" ? try(local.raw.environments[var.environment_name], {}) : {} - - # Validate target group name length (AWS limit: 32 chars) - _validate_tg_name = ( - length("${var.project}-${local.qualified_name}") <= 32 - ) ? true : tobool("Target group name '${var.project}-${local.qualified_name}' exceeds 32 chars. Use a shorter app name or environment name.") - - # Default compute values vary by environment (dev gets smaller defaults) - _default_cpu = var.environment_name == "dev" ? 256 : 512 - _default_memory = var.environment_name == "dev" ? 512 : 1024 - - # Compute (env overrides -> top-level -> defaults) - port = try(local.env_config.compute.port, local.raw.compute.port, 8000) - health_check = try(local.env_config.compute.health_check, local.raw.compute.health_check, "/health") - health_check_matcher = try(local.env_config.compute.health_check_matcher, local.raw.compute.health_check_matcher, "200") - cpu = try(local.env_config.compute.cpu, local.raw.compute.cpu, local._default_cpu) - memory = try(local.env_config.compute.memory, local.raw.compute.memory, local._default_memory) - desired_count = try(local.env_config.compute.desired_count, local.raw.compute.desired_count, 1) - - # Routing (env overrides -> env domain alias -> top-level -> top-level domain alias) - _routing_host = try( - local.env_config.routing.host, - local.env_config.domain, - local.raw.routing.host, - local.raw.domain, - null - ) - _routing_priority = try( - local.env_config.routing.priority, - local.raw.routing.priority, - null - ) - _validate_routing = ( - local._routing_host != null && - local._routing_priority != null - ) ? true : tobool("app.yaml must define routing.host (or domain) and routing.priority") - host = local._routing_host - priority = local._routing_priority - - # Resources (env overrides replace entire section; top-level is fallback) - buckets = try(local.env_config.resources.buckets, local.raw.resources.buckets, []) - databases = try(local.env_config.resources.databases, local.raw.resources.databases, []) - secrets = try(local.env_config.resources.secrets, local.raw.resources.secrets, []) - queues = try(local.env_config.resources.queues, local.raw.resources.queues, []) - - # Alarms (env overrides -> top-level -> defaults) - alarms_enabled = try(local.env_config.alarms.enabled, local.raw.alarms.enabled, true) - cpu_threshold = try(local.env_config.alarms.cpu_threshold, local.raw.alarms.cpu_threshold, 80) - memory_threshold = try(local.env_config.alarms.memory_threshold, local.raw.alarms.memory_threshold, 80) - error_5xx_threshold = try(local.env_config.alarms.error_5xx_threshold, local.raw.alarms.error_5xx_threshold, 10) - - # Convert lists -> maps keyed by name (stable for_each keys) - bucket_map = { for b in local.buckets : b.name => b } - database_map = { for d in local.databases : d.name => d } - secret_map = { for s in local.secrets : s.name => s } - queue_map = { for q in local.queues : q.name => q } - - # Auto-wired secret env vars (YAML secrets -> ECS secrets map) - # Secrets with env field are injected into the ECS task definition - yaml_secrets_map = { - for s in local.secrets : s.env => module.secrets[s.name].secret_arn - if try(s.env, "") != "" - } - all_secrets = merge(local.yaml_secrets_map, var.additional_secrets) - - # Auto-wired resource env vars (resources with env field -> container env) - bucket_env = { - for b in local.buckets : b.env => module.buckets[b.name].bucket_name - if try(b.env, "") != "" - } - database_env = { - for d in local.databases : d.env => module.databases[d.name].table_name - if try(d.env, "") != "" - } - queue_env = { - for q in local.queues : q.env => module.queues[q.name].queue_url - if try(q.env, "") != "" - } - - # Environment variables: merge top-level + env-specific (env wins on conflict) - yaml_environment = merge( - try(local.raw.environment, {}), - try(local.env_config.environment, {}), - ) - all_environment = merge( - local.yaml_environment, - local.bucket_env, - local.database_env, - local.queue_env, - var.additional_environment, - ) - - # Auto-composed IAM policies from all resources (keyed map for stable for_each) - all_policy_jsons = merge( - { for name, _ in local.bucket_map : "bucket-${name}" => module.buckets[name].access_policy_json }, - { for name, _ in local.database_map : "database-${name}" => module.databases[name].access_policy_json }, - { for name, _ in local.secret_map : "secret-${name}" => module.secrets[name].access_policy_json }, - { for name, _ in local.queue_map : "queue-${name}" => module.queues[name].access_policy_json }, - var.additional_policy_jsons, - ) - - # ECR URL (from created module or data source) - ecr_url = var.create_ecr ? module.ecr[0].repository_url : data.aws_ecr_repository.existing[0].repository_url - ecr_name = var.create_ecr ? module.ecr[0].repository_name : data.aws_ecr_repository.existing[0].name - ecr_arn = var.create_ecr ? module.ecr[0].repository_arn : data.aws_ecr_repository.existing[0].arn -} - -################################################################################ -# 1. Platform Data (discover shared infra) -################################################################################ - -module "platform" { - source = "../platform-data" -} - -################################################################################ -# 2. ECR Repository (shared across environments) -################################################################################ - -module "ecr" { - source = "../ecr-repo" - count = var.create_ecr ? 1 : 0 - name = local.name # base name, shared across environments -} - -data "aws_ecr_repository" "existing" { - count = var.create_ecr ? 0 : 1 - name = local.name -} - -################################################################################ -# 3. ALB Routing (target group + listener rule + DNS) -################################################################################ - -module "routing" { - source = "../service-routing" - - name = local.qualified_name - project = var.project - vpc_id = module.platform.vpc_id - port = local.port - health_check_path = local.health_check - health_check_matcher = local.health_check_matcher - https_listener_arn = module.platform.https_listener_arn - listener_rule_priority = local.priority - host_header = local.host - route53_zone_id = module.platform.route53_zone_id - alb_dns_name = module.platform.alb_dns_name - alb_zone_id = module.platform.alb_zone_id -} - -################################################################################ -# 4. S3 Buckets -################################################################################ - -module "buckets" { - source = "../service-bucket" - for_each = local.bucket_map - - name = each.key - project = local.qualified_name - aws_account_id = var.aws_account_id - service = local.qualified_name - versioning = try(each.value.versioning, true) - expire_days = try(each.value.expire_days, 0) -} - -################################################################################ -# 5. DynamoDB Tables -################################################################################ - -module "databases" { - source = "../service-database" - for_each = local.database_map - - name = each.key - project = local.qualified_name - service = local.qualified_name - hash_key = try(each.value.hash_key, "id") - hash_key_type = try(each.value.hash_key_type, "S") - range_key = try(each.value.range_key, null) - range_key_type = try(each.value.range_key_type, "S") - ttl_attribute = try(each.value.ttl_attribute, "") -} - -################################################################################ -# 6. Secrets Manager -################################################################################ - -module "secrets" { - source = "../service-secret" - for_each = local.secret_map - - name = each.key - project = local.qualified_name - service = local.qualified_name - description = try(each.value.description, "") -} - -################################################################################ -# 7. SQS Queues -################################################################################ - -module "queues" { - source = "../service-queue" - for_each = local.queue_map - - name = each.key - project = local.qualified_name - service = local.qualified_name - visibility_timeout_seconds = try(each.value.visibility_timeout, 30) - retention_seconds = try(each.value.retention_seconds, 345600) - max_receive_count = try(each.value.max_receive_count, 3) -} - -################################################################################ -# 8. IAM Task Role (auto-composed policies) -################################################################################ - -module "task_role" { - source = "../service-role" - - name = local.qualified_name - project = var.project - region = var.region - aws_account_id = var.aws_account_id - permissions_boundary_arn = module.platform.developer_boundary_arn - additional_policy_jsons = local.all_policy_jsons -} - -################################################################################ -# 9. ECS Fargate Service -################################################################################ - -module "service" { - source = "../ecs-service" - - name = local.qualified_name - cluster_id = module.platform.ecs_cluster_id - image = "${local.ecr_url}:${var.image_tag}" - cpu = local.cpu - memory = local.memory - port = local.port - health_check_path = local.health_check - desired_count = local.desired_count - - execution_role_arn = module.platform.execution_role_arn - task_role_arn = module.task_role.role_arn - subnet_ids = module.platform.private_subnet_ids - security_group_ids = [module.platform.ecs_tasks_security_group_id] - target_group_arn = module.routing.target_group_arn - region = var.region - - environment = local.all_environment - secrets = local.all_secrets -} - -################################################################################ -# 10. CloudWatch Alarms (optional) -################################################################################ - -module "alarms" { - source = "../service-alarm" - count = local.alarms_enabled ? 1 : 0 - - name = local.qualified_name - project = var.project - service = local.qualified_name - cluster_name = module.platform.ecs_cluster_name - sns_topic_arns = [data.aws_sns_topic.alerts.arn] - cpu_threshold = local.cpu_threshold - memory_threshold = local.memory_threshold - error_5xx_threshold = local.error_5xx_threshold - enable_alb_alarms = true - target_group_arn_suffix = module.routing.target_group_arn_suffix - alb_arn_suffix = module.platform.alb_arn_suffix -} - -data "aws_sns_topic" "alerts" { - name = "${var.project}-alerts" -} diff --git a/terraform/modules/app-stack/outputs.tf b/terraform/modules/app-stack/outputs.tf deleted file mode 100644 index 04c2cd1..0000000 --- a/terraform/modules/app-stack/outputs.tf +++ /dev/null @@ -1,152 +0,0 @@ -################################################################################ -# Container Image -################################################################################ - -output "ecr_url" { - description = "ECR repository URL" - value = local.ecr_url -} - -output "ecr_name" { - description = "ECR repository name" - value = local.ecr_name -} - -################################################################################ -# ECS -################################################################################ - -output "service_name" { - description = "ECS service name" - value = module.service.service_name -} - -output "task_definition_arn" { - description = "ECS task definition ARN" - value = module.service.task_definition_arn -} - -output "log_group_name" { - description = "CloudWatch log group name" - value = module.service.log_group_name -} - -################################################################################ -# IAM -################################################################################ - -output "task_role_arn" { - description = "ECS task IAM role ARN" - value = module.task_role.role_arn -} - -output "task_role_id" { - description = "ECS task IAM role ID (for attaching additional inline policies)" - value = module.task_role.role_id -} - -output "task_role_name" { - description = "ECS task IAM role name" - value = module.task_role.role_name -} - -################################################################################ -# Networking -################################################################################ - -output "dns_name" { - description = "FQDN created in Route53" - value = module.routing.dns_name -} - -output "target_group_arn" { - description = "ALB target group ARN" - value = module.routing.target_group_arn -} - -################################################################################ -# Resources (dynamic maps keyed by YAML name) -################################################################################ - -output "bucket_names" { - description = "Map of YAML bucket name => S3 bucket name" - value = { for name, mod in module.buckets : name => mod.bucket_name } -} - -output "bucket_arns" { - description = "Map of YAML bucket name => S3 bucket ARN" - value = { for name, mod in module.buckets : name => mod.bucket_arn } -} - -output "table_names" { - description = "Map of YAML database name => DynamoDB table name" - value = { for name, mod in module.databases : name => mod.table_name } -} - -output "table_arns" { - description = "Map of YAML database name => DynamoDB table ARN" - value = { for name, mod in module.databases : name => mod.table_arn } -} - -output "secret_arns" { - description = "Map of YAML secret name => Secrets Manager secret ARN" - value = { for name, mod in module.secrets : name => mod.secret_arn } -} - -output "queue_urls" { - description = "Map of YAML queue name => SQS queue URL" - value = { for name, mod in module.queues : name => mod.queue_url } -} - -output "queue_arns" { - description = "Map of YAML queue name => SQS queue ARN" - value = { for name, mod in module.queues : name => mod.queue_arn } -} - -output "dlq_urls" { - description = "Map of YAML queue name => SQS dead-letter queue URL" - value = { for name, mod in module.queues : name => mod.dlq_url } -} - -################################################################################ -# Platform (passthrough for custom Terraform) -################################################################################ - -output "platform" { - description = "All shared infra values (vpc_id, subnet_ids, etc.) for custom Terraform" - value = { - vpc_id = module.platform.vpc_id - private_subnet_ids = module.platform.private_subnet_ids - public_subnet_ids = module.platform.public_subnet_ids - ecs_cluster_id = module.platform.ecs_cluster_id - ecs_cluster_name = module.platform.ecs_cluster_name - ecs_tasks_security_group_id = module.platform.ecs_tasks_security_group_id - execution_role_arn = module.platform.execution_role_arn - https_listener_arn = module.platform.https_listener_arn - route53_zone_id = module.platform.route53_zone_id - alb_arn = module.platform.alb_arn - alb_dns_name = module.platform.alb_dns_name - alb_zone_id = module.platform.alb_zone_id - region = module.platform.region - project = module.platform.project - } -} - -################################################################################ -# App config -################################################################################ - -output "app_name" { - description = "Application name from YAML (base name without environment suffix)" - value = local.name -} - -output "environment_name" { - description = "Environment name (empty string for single-environment apps)" - value = var.environment_name -} - -output "qualified_name" { - description = "Environment-qualified name used for resource naming (e.g. 'moresleep-dev')" - value = local.qualified_name -} diff --git a/terraform/modules/app-stack/variables.tf b/terraform/modules/app-stack/variables.tf deleted file mode 100644 index 7f2ef4b..0000000 --- a/terraform/modules/app-stack/variables.tf +++ /dev/null @@ -1,63 +0,0 @@ -variable "config_file" { - description = "Path to app.yaml relative to the Terraform root module" - type = string - default = "../app.yaml" -} - -variable "environment_name" { - description = "Environment name (e.g. 'dev', 'prod'). Empty string for single-environment apps." - type = string - default = "" - - validation { - condition = var.environment_name == "" || can(regex("^[a-z][a-z0-9]{0,3}$", var.environment_name)) - error_message = "environment_name must be lowercase alphanumeric, max 4 chars (e.g. dev, prod, test)." - } -} - -variable "create_ecr" { - description = "Whether to create the ECR repository. Set false for secondary environments sharing an ECR repo." - type = bool - default = true -} - -variable "aws_account_id" { - description = "AWS account ID for ARN construction and bucket naming" - type = string -} - -variable "project" { - description = "Platform project name (e.g. 'javabin')" - type = string - default = "javabin" -} - -variable "region" { - description = "AWS region" - type = string - default = "eu-central-1" -} - -variable "image_tag" { - description = "Container image tag (default: 'latest'). CI overrides via TF_VAR_image_tag." - type = string - default = "latest" -} - -variable "additional_environment" { - description = "Additional environment variables merged with YAML-defined ones (overrides on conflict)" - type = map(string) - default = {} -} - -variable "additional_secrets" { - description = "Additional secrets merged with YAML-defined ones (env_var_name => secret_arn)" - type = map(string) - default = {} -} - -variable "additional_policy_jsons" { - description = "Additional IAM policies attached to the task role (name => policy JSON)" - type = map(string) - default = {} -}