diff --git a/.github/workflows/approve-override.yml b/.github/workflows/approve-override.yml index baeae32..bc216a2 100644 --- a/.github/workflows/approve-override.yml +++ b/.github/workflows/approve-override.yml @@ -31,6 +31,10 @@ jobs: approve: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: scripts + - name: Configure AWS credentials via OIDC uses: aws-actions/configure-aws-credentials@v4 with: @@ -38,65 +42,9 @@ jobs: aws-region: ${{ env.AWS_REGION }} - name: Write override token - env: - OVERRIDE_REPO: ${{ inputs.repo }} - OVERRIDE_SHA: ${{ inputs.sha }} - OVERRIDE_REASON: ${{ inputs.reason }} - OVERRIDE_ACTOR: ${{ github.actor }} - run: | - PARAM_NAME="/javabin/platform-overrides/${OVERRIDE_REPO}/${OVERRIDE_SHA}" - - python3 << 'PYEOF' - import json, os - value = json.dumps({ - "approved_by": os.environ["OVERRIDE_ACTOR"], - "reason": os.environ["OVERRIDE_REASON"], - "approved_at": __import__('datetime').datetime.utcnow().isoformat() + "Z", - "run_id": os.environ.get("GITHUB_RUN_ID", ""), - }) - with open("/tmp/override-value.txt", "w") as f: - f.write(value) - PYEOF + run: sh scripts/write-override-token.sh "${{ inputs.repo }}" "${{ inputs.sha }}" "${{ github.actor }}" "${{ inputs.reason }}" - aws ssm put-parameter \ - --name "$PARAM_NAME" \ - --type String \ - --value "$(cat /tmp/override-value.txt)" \ - --overwrite - - echo "Override token written: ${PARAM_NAME}" - - - name: Post to Slack + - name: Notify Slack env: - OVERRIDE_REPO: ${{ inputs.repo }} - OVERRIDE_SHA: ${{ inputs.sha }} - OVERRIDE_REASON: ${{ inputs.reason }} - OVERRIDE_ACTOR: ${{ github.actor }} - GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - export SLACK_WEBHOOK_URL=$(aws ssm get-parameter \ - --name /javabin/slack/platform-override-alerts-webhook \ - --with-decryption --query Parameter.Value --output text) - - python3 << 'PYEOF' - import json, os, urllib.request - webhook_url = os.environ.get("SLACK_WEBHOOK_URL", "") - if not webhook_url: - print("No webhook URL, skipping Slack notification") - exit(0) - run_url = os.environ["GITHUB_RUN_URL"] - repo = os.environ["OVERRIDE_REPO"] - sha = os.environ["OVERRIDE_SHA"] - actor = os.environ["OVERRIDE_ACTOR"] - reason = os.environ["OVERRIDE_REASON"] - payload = json.dumps({ - "blocks": [ - {"type": "header", "text": {"type": "plain_text", "text": "Risk Override Approved", "emoji": True}}, - {"type": "section", "text": {"type": "mrkdwn", "text": f"*Repo:* {repo}\n*SHA:* `{sha}`\n*By:* {actor}\n*Reason:* {reason}"}}, - {"type": "section", "text": {"type": "mrkdwn", "text": f"<{run_url}|View Approval Run>"}}, - ], - "text": f"Risk override approved for {repo}" - }).encode() - req = urllib.request.Request(webhook_url, data=payload, headers={"Content-Type": "application/json"}) - urllib.request.urlopen(req) - PYEOF + SSM_WEBHOOK_PARAM: /javabin/slack/platform-override-alerts-webhook + run: sh scripts/notify-slack.sh "Risk Override Approved" "*Repo:* ${{ inputs.repo }}\n*SHA:* \`${{ inputs.sha }}\`\n*By:* ${{ github.actor }}\n*Reason:* ${{ inputs.reason }}" "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" "View Approval Run" diff --git a/.github/workflows/commit-terraform.yml b/.github/workflows/commit-terraform.yml index c5c7551..a0e1611 100644 --- a/.github/workflows/commit-terraform.yml +++ b/.github/workflows/commit-terraform.yml @@ -33,13 +33,7 @@ jobs: - name: Check for app.yaml id: check - run: | - if [ -f app.yaml ]; then - echo "has_yaml=true" >> "$GITHUB_OUTPUT" - else - echo "has_yaml=false" >> "$GITHUB_OUTPUT" - echo "No app.yaml — nothing to commit." - fi + run: echo "has_yaml=$(test -f app.yaml && echo true || echo false)" >> "$GITHUB_OUTPUT" - uses: hashicorp/setup-terraform@v3 if: steps.check.outputs.has_yaml == 'true' @@ -73,27 +67,8 @@ jobs: AWS_ACCOUNT_ID: ${{ inputs.aws_account_id }} AWS_REGION: ${{ inputs.aws_region }} TF_ROOT: ${{ inputs.tf_root }} - run: | - chmod +x .platform/scripts/generate-terraform.sh - .platform/scripts/generate-terraform.sh + run: sh .platform/scripts/generate-terraform.sh - name: Commit and push generated files if: steps.check.outputs.has_yaml == 'true' - run: | - git config user.name "javabin-platform[bot]" - git config user.email "platform@javazone.no" - - git add "${{ inputs.tf_root }}/" - - if git diff --cached --quiet; then - echo "Generated Terraform files are already up to date." - exit 0 - fi - - git commit -m "[skip ci] Update generated Terraform from app.yaml - - Auto-generated by platform CI after successful apply. - Source: app.yaml → generate-terraform.sh" - - git push origin HEAD:${{ github.ref_name }} - echo "Committed generated Terraform files." + run: sh .platform/scripts/commit-generated-tf.sh "${{ inputs.tf_root }}" "${{ github.ref_name }}" diff --git a/.github/workflows/detect.yml b/.github/workflows/detect.yml index b9132aa..0c81571 100644 --- a/.github/workflows/detect.yml +++ b/.github/workflows/detect.yml @@ -53,10 +53,8 @@ jobs: echo "has_pnpm=$(test -f pnpm-lock.yaml && echo true || echo false)" >> "$GITHUB_OUTPUT" echo "has_eb=$(test -d .elasticbeanstalk && echo true || echo false)" >> "$GITHUB_OUTPUT" echo "has_cdk=$(test -f cdk.json && echo true || echo false)" >> "$GITHUB_OUTPUT" - if [ -f app.yaml ]; then - APP_NAME=$(grep -m1 '^name:' app.yaml | awk '{print $2}' | tr -d '"'"'" || echo "") - echo "app_name=${APP_NAME}" >> "$GITHUB_OUTPUT" + echo "app_name=$(grep -m1 '^name:' app.yaml | awk '{print $2}' | tr -d '\"'"'")" >> "$GITHUB_OUTPUT" else echo "app_name=" >> "$GITHUB_OUTPUT" fi diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 165520e..d2239a4 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -85,5 +85,4 @@ jobs: - name: Set output id: push - run: | - echo "image_uri=${{ steps.tags.outputs.repo }}:${{ steps.tags.outputs.primary_tag }}" >> "$GITHUB_OUTPUT" + run: echo "image_uri=${{ steps.tags.outputs.repo }}:${{ steps.tags.outputs.primary_tag }}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ecs-deploy.yml b/.github/workflows/ecs-deploy.yml index 952a8f5..8879ba7 100644 --- a/.github/workflows/ecs-deploy.yml +++ b/.github/workflows/ecs-deploy.yml @@ -32,13 +32,30 @@ jobs: deploy: runs-on: ubuntu-latest steps: + - 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 }} + ref: main + sparse-checkout: scripts + path: .platform + - name: Configure AWS credentials via OIDC uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::${{ inputs.aws_account_id }}:role/javabin-ci-deploy-${{ github.event.repository.name }} aws-region: ${{ inputs.aws_region }} - - name: Update ECS service + - name: Deploy to ECS env: SERVICE: ${{ inputs.service_name || github.event.repository.name }} CLUSTER: ${{ inputs.cluster_name }} @@ -46,42 +63,4 @@ jobs: ECR_REPO: ${{ github.event.repository.name }} ACCOUNT_ID: ${{ inputs.aws_account_id }} REGION: ${{ inputs.aws_region }} - run: | - export ECR_URI="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/${ECR_REPO}:${IMAGE_TAG}" - - # Get current task definition from the service - TASK_DEF_ARN=$(aws ecs describe-services \ - --cluster "$CLUSTER" --services "$SERVICE" \ - --query 'services[0].taskDefinition' --output text) - - # Fetch and update the task definition - aws ecs describe-task-definition --task-definition "$TASK_DEF_ARN" \ - --query 'taskDefinition' > task-def.json - - python3 << 'PYEOF' - import json, os - with open('task-def.json') as f: - td = json.load(f) - td['containerDefinitions'][0]['image'] = os.environ['ECR_URI'] - for key in ['taskDefinitionArn', 'revision', 'status', 'requiresAttributes', - 'compatibilities', 'registeredAt', 'registeredBy', 'deregisteredAt']: - td.pop(key, None) - with open('task-def-new.json', 'w') as f: - json.dump(td, f) - PYEOF - - # Register new task definition - NEW_ARN=$(aws ecs register-task-definition \ - --cli-input-json file://task-def-new.json \ - --query 'taskDefinition.taskDefinitionArn' --output text) - echo "New task definition: $NEW_ARN" - - # Update the service - aws ecs update-service \ - --cluster "$CLUSTER" --service "$SERVICE" \ - --task-definition "$NEW_ARN" > /dev/null - - # Wait for deployment to stabilize - echo "Waiting for service to stabilize..." - aws ecs wait services-stable --cluster "$CLUSTER" --services "$SERVICE" - echo "Deployment complete." + run: sh .platform/scripts/ecs-deploy.sh diff --git a/.github/workflows/plan-review.yml b/.github/workflows/plan-review.yml index f6641c4..bd0da40 100644 --- a/.github/workflows/plan-review.yml +++ b/.github/workflows/plan-review.yml @@ -46,7 +46,7 @@ jobs: repository: javaBin/platform token: ${{ steps.app-token.outputs.token }} ref: main - sparse-checkout: scripts/review-plan.py + sparse-checkout: scripts path: platform - name: Configure AWS credentials via OIDC @@ -56,24 +56,13 @@ jobs: aws-region: ${{ inputs.aws_region }} - name: Download plan text from S3 - run: | - PREFIX=$(dirname "${{ inputs.plan_key }}") - aws s3 cp "s3://${PLAN_BUCKET}/${PREFIX}/plan-output.txt" plan-output.txt + run: aws s3 cp "s3://${PLAN_BUCKET}/$(dirname "${{ inputs.plan_key }}")/plan-output.txt" plan-output.txt - name: Run LLM review id: review env: REVIEW_RESULT_PATH: review-result.json - run: | - python3 platform/scripts/review-plan.py plan-output.txt 2>&1 | tee review-output.txt || true - - if [ -f review-result.json ]; then - RISK=$(python3 -c "import json; print(json.load(open('review-result.json')).get('risk', 'FAILED'))") - else - RISK="FAILED" - fi - echo "risk_level=${RISK}" >> "$GITHUB_OUTPUT" - echo "LLM review risk: ${RISK}" + run: sh platform/scripts/extract-review-risk.sh platform/scripts/review-plan.py plan-output.txt - name: Post review to PR if: github.event_name == 'pull_request' @@ -110,40 +99,6 @@ jobs: body: body }); - - name: Post to Slack on direct push - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - env: - GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - export SLACK_WEBHOOK_URL=$(aws ssm get-parameter \ - --name /javabin/slack/platform-resource-alerts-webhook \ - --with-decryption --query Parameter.Value --output text) - - python3 << 'PYEOF' - import json, os, urllib.request - webhook_url = os.environ.get("SLACK_WEBHOOK_URL", "") - if not webhook_url: - print("No webhook URL, skipping Slack notification") - exit(0) - run_url = os.environ["GITHUB_RUN_URL"] - repo = os.environ.get("GITHUB_REPOSITORY", "unknown") - try: - with open("review-result.json") as f: - result = json.load(f) - risk = result.get("risk", "UNKNOWN") - summary = result.get("summary", "No summary") - except Exception: - risk = "FAILED" - summary = "Review failed" - emoji = {"LOW": "\U0001F7E2", "MEDIUM": "\U0001F7E1", "HIGH": "\U0001F534"}.get(risk, "\u26AA") - payload = json.dumps({ - "blocks": [ - {"type": "header", "text": {"type": "plain_text", "text": f"{emoji} Plan Review: {risk}", "emoji": True}}, - {"type": "section", "text": {"type": "mrkdwn", "text": f"*Repo:* {repo}\n*Summary:* {summary}"}}, - {"type": "section", "text": {"type": "mrkdwn", "text": f"<{run_url}|View Workflow Run>"}}, - ], - "text": f"Plan review: {risk} for {repo}" - }).encode() - req = urllib.request.Request(webhook_url, data=payload, headers={"Content-Type": "application/json"}) - urllib.request.urlopen(req) - PYEOF + - name: Alert Slack on HIGH risk + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.review.outputs.risk_level == 'HIGH' + run: sh platform/scripts/notify-high-risk.sh /javabin/slack/platform-override-alerts-webhook "https://github.com/javaBin/platform/actions/workflows/approve-override.yml" diff --git a/.github/workflows/platform-ci.yml b/.github/workflows/platform-ci.yml index 67a8ef1..c897032 100644 --- a/.github/workflows/platform-ci.yml +++ b/.github/workflows/platform-ci.yml @@ -73,39 +73,12 @@ jobs: - name: Terraform Plan id: plan - working-directory: ${{ env.TF_ROOT }} - run: | - set +e - terraform plan -out=tfplan -detailed-exitcode -no-color -lock-timeout=5m > plan-output.txt 2>&1 - PLAN_EXIT=$? - set -e - - cat plan-output.txt - - if [ "$PLAN_EXIT" = "1" ]; then - echo "Terraform plan failed" - exit 1 - elif [ "$PLAN_EXIT" = "2" ]; then - echo "has_changes=true" >> "$GITHUB_OUTPUT" - else - echo "has_changes=false" >> "$GITHUB_OUTPUT" - echo "No changes — downstream jobs will skip." - fi + run: scripts/run-plan.sh "${{ env.TF_ROOT }}" -lock-timeout=5m - name: Upload plan and output to S3 id: upload if: steps.plan.outputs.has_changes == 'true' - working-directory: ${{ env.TF_ROOT }} - run: | - PREFIX="plans/${{ github.repository }}/${{ github.run_id }}" - SHA256=$(sha256sum tfplan | awk '{print $1}') - - aws s3 cp tfplan "s3://${PLAN_BUCKET}/${PREFIX}/tfplan" - aws s3 cp plan-output.txt "s3://${PLAN_BUCKET}/${PREFIX}/plan-output.txt" - - echo "plan_key=${PREFIX}/tfplan" >> "$GITHUB_OUTPUT" - echo "plan_text_key=${PREFIX}/plan-output.txt" >> "$GITHUB_OUTPUT" - echo "plan_sha256=${SHA256}" >> "$GITHUB_OUTPUT" + run: scripts/upload-plan.sh "${{ env.TF_ROOT }}" - name: Upload Lambda ZIPs as artifact if: steps.plan.outputs.has_changes == 'true' @@ -175,25 +148,13 @@ jobs: role-session-name: javabin-review-${{ github.run_id }} - name: Download plan text from S3 - run: | - mkdir -p "${{ env.TF_ROOT }}" - aws s3 cp "s3://${PLAN_BUCKET}/${{ needs.plan.outputs.plan_text_key }}" \ - "${{ env.TF_ROOT }}/plan-output.txt" + run: mkdir -p "${{ env.TF_ROOT }}" && aws s3 cp "s3://${PLAN_BUCKET}/${{ needs.plan.outputs.plan_text_key }}" "${{ env.TF_ROOT }}/plan-output.txt" - name: Run LLM review id: review env: REVIEW_RESULT_PATH: review-result.json - run: | - python3 scripts/review-plan.py "${{ env.TF_ROOT }}/plan-output.txt" 2>&1 | tee review-output.txt || true - - if [ -f review-result.json ]; then - RISK=$(python3 -c "import json; print(json.load(open('review-result.json')).get('risk', 'FAILED'))") - else - RISK="FAILED" - fi - echo "risk_level=${RISK}" >> "$GITHUB_OUTPUT" - echo "LLM review risk: ${RISK}" + run: sh scripts/extract-review-risk.sh scripts/review-plan.py "${{ env.TF_ROOT }}/plan-output.txt" - name: Post review to PR if: github.event_name == 'pull_request' @@ -232,36 +193,7 @@ jobs: - name: Post HIGH risk to Slack if: steps.review.outputs.risk_level == 'HIGH' && github.ref == 'refs/heads/main' - env: - GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - export SLACK_WEBHOOK_URL=$(aws ssm get-parameter \ - --name /javabin/slack/platform-resource-alerts-webhook \ - --with-decryption --query Parameter.Value --output text) - - python3 << 'PYEOF' - import json, os, urllib.request - run_url = os.environ["GITHUB_RUN_URL"] - webhook_url = os.environ["SLACK_WEBHOOK_URL"] - try: - with open("review-result.json") as f: - review = json.load(f) - summary = review.get("summary", "Unknown") - except Exception: - summary = "Review failed" - payload = json.dumps({ - "blocks": [ - {"type": "header", "text": {"type": "plain_text", "text": "Terraform Apply Blocked — HIGH Risk Plan", "emoji": True}}, - {"type": "section", "text": {"type": "mrkdwn", "text": f"*Summary:* {summary}"}}, - {"type": "section", "text": {"type": "mrkdwn", "text": f"<{run_url}|View Workflow Run> — apply is paused, human review required."}}, - {"type": "divider"}, - {"type": "context", "elements": [{"type": "mrkdwn", "text": "Source: LLM Plan Review (eu.anthropic.claude-haiku-4-5-20251001-v1:0)"}]}, - ], - "text": f"Terraform apply blocked: {summary}" - }).encode() - req = urllib.request.Request(webhook_url, data=payload, headers={"Content-Type": "application/json"}) - urllib.request.urlopen(req) - PYEOF + run: sh scripts/notify-high-risk.sh /javabin/slack/platform-resource-alerts-webhook "https://github.com/javaBin/platform/actions/workflows/approve-override.yml" # -------------------------------------------------------------------------- # Apply — auto-apply on LOW/MEDIUM, block on HIGH @@ -292,17 +224,7 @@ jobs: - name: Check risk level env: RISK: ${{ needs.review.outputs.risk_level }} - run: | - echo "LLM review risk: ${RISK}" - if [ "$RISK" = "HIGH" ] || [ "$RISK" = "FAILED" ] || [ "$RISK" = "" ]; then - echo "Auto-apply blocked (risk=${RISK}). Notifying Slack..." - export WEBHOOK_URL=$(aws ssm get-parameter \ - --name /javabin/slack/platform-override-alerts-webhook \ - --with-decryption --query Parameter.Value --output text 2>/dev/null || echo "") - export OVERRIDE_URL="https://github.com/${{ github.repository }}/actions/workflows/approve-override.yml" - python3 scripts/notify-block.py || true - exit 1 - fi + run: sh scripts/check-risk-block.sh "$RISK" /javabin/slack/platform-override-alerts-webhook "https://github.com/${{ github.repository }}/actions/workflows/approve-override.yml" - name: Download Lambda ZIPs from artifact uses: actions/download-artifact@v4 @@ -312,19 +234,11 @@ jobs: - name: Download plan from S3 working-directory: ${{ env.TF_ROOT }} - run: | - aws s3 cp "s3://${PLAN_BUCKET}/${{ needs.plan.outputs.plan_key }}" tfplan + run: aws s3 cp "s3://${PLAN_BUCKET}/${{ needs.plan.outputs.plan_key }}" tfplan - name: Verify plan integrity working-directory: ${{ env.TF_ROOT }} - run: | - EXPECTED="${{ needs.plan.outputs.plan_sha256 }}" - ACTUAL=$(sha256sum tfplan | awk '{print $1}') - if [ "$EXPECTED" != "$ACTUAL" ]; then - echo "Plan SHA256 mismatch! Expected: ${EXPECTED}, Got: ${ACTUAL}" - exit 1 - fi - echo "Plan integrity verified." + run: sh "${{ github.workspace }}/scripts/verify-plan.sh" tfplan "${{ needs.plan.outputs.plan_sha256 }}" - name: Terraform Init working-directory: ${{ env.TF_ROOT }} @@ -361,42 +275,4 @@ jobs: - name: Check for drift working-directory: ${{ env.TF_ROOT }} - run: | - set +e - terraform plan -detailed-exitcode -no-color -lock-timeout=5m > drift.txt 2>&1 - EXIT_CODE=$? - set -e - - if [ "$EXIT_CODE" = "2" ]; then - echo "Drift detected!" - export DRIFT_RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - - export DRIFT_WEBHOOK_URL=$(aws ssm get-parameter \ - --name /javabin/slack/platform-resource-alerts-webhook \ - --with-decryption --query Parameter.Value --output text) - - python3 << 'PYEOF' - import json, os, urllib.request - with open("drift.txt") as f: - drift = f.read()[:2800] - run_url = os.environ["DRIFT_RUN_URL"] - webhook_url = os.environ["DRIFT_WEBHOOK_URL"] - payload = json.dumps({ - "blocks": [ - {"type": "header", "text": {"type": "plain_text", "text": "Terraform Drift Detected", "emoji": True}}, - {"type": "section", "text": {"type": "mrkdwn", "text": "Someone made changes outside of Terraform."}}, - {"type": "section", "text": {"type": "mrkdwn", "text": f"```{drift}```"}}, - {"type": "section", "text": {"type": "mrkdwn", "text": f"<{run_url}|View Full Plan>"}}, - ], - "text": "Terraform drift detected" - }).encode() - req = urllib.request.Request(webhook_url, data=payload, headers={"Content-Type": "application/json"}) - urllib.request.urlopen(req) - PYEOF - exit 1 - elif [ "$EXIT_CODE" = "1" ]; then - echo "Terraform plan failed during drift detection." - exit 1 - else - echo "No drift detected." - fi + run: sh "${{ github.workspace }}/scripts/drift-check.sh" /javabin/slack/platform-resource-alerts-webhook "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/provision-app.yml b/.github/workflows/provision-app.yml deleted file mode 100644 index bcc6ae4..0000000 --- a/.github/workflows/provision-app.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Provision App - -# Triggered by javaBin/registry when an app registration is merged. -# Reads app names from the dispatch payload, fetches their YAML from the -# registry, and updates registered-apps.auto.tfvars. The commit triggers -# platform-ci.yml which runs plan -> review -> apply to create IAM roles. - -on: - repository_dispatch: - types: [registry-update] - -permissions: - contents: write - -env: - TF_ROOT: terraform/platform - -jobs: - provision: - runs-on: ubuntu-latest - steps: - - 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 - - - uses: actions/checkout@v4 - with: - token: ${{ steps.app-token.outputs.token }} - - - name: Process registry update - env: - DISPATCH_APPS: ${{ github.event.client_payload.apps }} - DISPATCH_TEAMS: ${{ github.event.client_payload.teams }} - run: | - echo "Registry update received" - echo " Apps: ${DISPATCH_APPS}" - echo " Teams: ${DISPATCH_TEAMS}" - - - name: Fetch all registered apps from registry - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - # List all app YAML files in the registry - APPS=$(gh api repos/javaBin/registry/contents/apps \ - --jq '.[].name' | grep '\.yaml$' | sed 's/\.yaml$//' | sort) - - if [ -z "$APPS" ]; then - echo "No apps registered in registry" - echo '[]' > /tmp/app-list.json - else - echo "$APPS" | jq -R -s 'split("\n") | map(select(length > 0))' > /tmp/app-list.json - fi - - echo "Registered apps:" - cat /tmp/app-list.json - - - name: Update registered-apps.auto.tfvars - env: - TFVARS_FILE: ${{ env.TF_ROOT }}/registered-apps.auto.tfvars - run: | - python3 << 'PYEOF' - import json, os - - tfvars_file = os.environ["TFVARS_FILE"] - - with open("/tmp/app-list.json") as f: - apps = json.load(f) - - lines = [ - "# GENERATED by provision-app workflow — do not edit manually.", - "# Source of truth: javaBin/registry/apps/", - "", - "registered_app_repos = [", - ] - for app in sorted(apps): - lines.append(f' "{app}",') - lines.append("]") - lines.append("") - - with open(tfvars_file, "w") as f: - f.write("\n".join(lines)) - PYEOF - - echo "Generated ${TFVARS_FILE}:" - cat "${TFVARS_FILE}" - - - name: Commit and push if changed - env: - TFVARS_FILE: ${{ env.TF_ROOT }}/registered-apps.auto.tfvars - DISPATCH_APPS: ${{ github.event.client_payload.apps }} - run: | - git config user.name "javabin-platform[bot]" - git config user.email "platform@javazone.no" - - git add "${TFVARS_FILE}" - - if git diff --cached --quiet; then - echo "No changes to registered apps — skipping commit." - else - git commit -m "Update registered apps from registry - - Triggered by repository_dispatch from javaBin/registry. - Apps: ${DISPATCH_APPS}" - - git push origin main - echo "Committed and pushed. Platform CI will now plan and apply." - fi diff --git a/.github/workflows/tf-apply.yml b/.github/workflows/tf-apply.yml index b5ffc3e..f59ea8f 100644 --- a/.github/workflows/tf-apply.yml +++ b/.github/workflows/tf-apply.yml @@ -46,67 +46,7 @@ jobs: terraform_version: "1.7" terraform_wrapper: false - - name: Configure AWS credentials via OIDC - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: arn:aws:iam::${{ inputs.aws_account_id }}:role/javabin-ci-app-${{ github.event.repository.name }} - aws-region: ${{ inputs.aws_region }} - - - name: Check risk level - env: - GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - RISK="${{ inputs.risk_level }}" - echo "LLM review risk: ${RISK}" - - if [ "$RISK" != "LOW" ] && [ "$RISK" != "MEDIUM" ]; then - echo "Checking for override token..." - REPO="${{ github.repository }}" - SHA="${{ github.sha }}" - OVERRIDE_KEY="/javabin/platform-overrides/${REPO}/${SHA}" - - set +e - aws ssm get-parameter --name "$OVERRIDE_KEY" --query Parameter.Value --output text > /dev/null 2>&1 - OVERRIDE_EXISTS=$? - set -e - - if [ "$OVERRIDE_EXISTS" = "0" ]; then - echo "Override token found — applying despite ${RISK} risk." - # Best-effort delete (single-use); override_cleanup Lambda handles stale tokens - aws ssm delete-parameter --name "$OVERRIDE_KEY" 2>/dev/null || true - else - echo "Auto-apply blocked (risk=${RISK}, no override token)." - echo "A board member can override via the approve-override workflow." - - # Post block notification to Slack - export BLOCK_WEBHOOK_URL=$(aws ssm get-parameter \ - --name /javabin/slack/platform-override-alerts-webhook \ - --with-decryption --query Parameter.Value --output text) - - python3 << 'PYEOF' - import json, os, urllib.request - webhook_url = os.environ.get("BLOCK_WEBHOOK_URL", "") - if not webhook_url: - exit(0) - run_url = os.environ["GITHUB_RUN_URL"] - repo = os.environ.get("GITHUB_REPOSITORY", "unknown") - payload = json.dumps({ - "blocks": [ - {"type": "header", "text": {"type": "plain_text", "text": "Terraform Apply Blocked", "emoji": True}}, - {"type": "section", "text": {"type": "mrkdwn", "text": f"*Repo:* {repo}\n*Reason:* HIGH risk plan — override required"}}, - {"type": "section", "text": {"type": "mrkdwn", "text": f"<{run_url}|View Workflow> | "}}, - ], - "text": f"Terraform apply blocked for {repo}" - }).encode() - req = urllib.request.Request(webhook_url, data=payload, headers={"Content-Type": "application/json"}) - urllib.request.urlopen(req) - PYEOF - exit 1 - fi - fi - - name: Generate GitHub App token - if: hashFiles('app.yaml') != '' id: app-token uses: actions/create-github-app-token@v1 with: @@ -115,7 +55,6 @@ jobs: owner: javaBin - name: Checkout platform scripts - if: hashFiles('app.yaml') != '' uses: actions/checkout@v4 with: repository: javaBin/platform @@ -123,6 +62,15 @@ jobs: path: .platform sparse-checkout: scripts + - name: Configure AWS credentials via OIDC + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::${{ inputs.aws_account_id }}:role/javabin-ci-app-${{ github.event.repository.name }} + aws-region: ${{ inputs.aws_region }} + + - 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: @@ -130,9 +78,7 @@ jobs: AWS_ACCOUNT_ID: ${{ inputs.aws_account_id }} AWS_REGION: ${{ inputs.aws_region }} TF_ROOT: ${{ inputs.tf_root }} - run: | - chmod +x .platform/scripts/generate-terraform.sh - .platform/scripts/generate-terraform.sh + run: sh .platform/scripts/generate-terraform.sh - name: Configure git credentials for module downloads if: hashFiles('app.yaml') != '' @@ -144,14 +90,7 @@ jobs: - name: Verify plan integrity working-directory: ${{ inputs.tf_root }} - run: | - EXPECTED="${{ inputs.plan_sha256 }}" - ACTUAL=$(sha256sum tfplan | awk '{print $1}') - if [ "$EXPECTED" != "$ACTUAL" ]; then - echo "Plan SHA256 mismatch! Expected: ${EXPECTED}, Got: ${ACTUAL}" - exit 1 - fi - echo "Plan integrity verified." + run: sh "${{ github.workspace }}/.platform/scripts/verify-plan.sh" tfplan "${{ inputs.plan_sha256 }}" - name: Terraform Init working-directory: ${{ inputs.tf_root }} diff --git a/.github/workflows/tf-plan.yml b/.github/workflows/tf-plan.yml index 9dde88f..4a0264d 100644 --- a/.github/workflows/tf-plan.yml +++ b/.github/workflows/tf-plan.yml @@ -55,7 +55,6 @@ jobs: aws-region: ${{ inputs.aws_region }} - name: Generate GitHub App token - if: hashFiles('app.yaml') != '' id: app-token uses: actions/create-github-app-token@v1 with: @@ -64,7 +63,6 @@ jobs: owner: javaBin - name: Checkout platform scripts - if: hashFiles('app.yaml') != '' uses: actions/checkout@v4 with: repository: javaBin/platform @@ -79,9 +77,7 @@ jobs: AWS_ACCOUNT_ID: ${{ inputs.aws_account_id }} AWS_REGION: ${{ inputs.aws_region }} TF_ROOT: ${{ inputs.tf_root }} - run: | - chmod +x .platform/scripts/generate-terraform.sh - .platform/scripts/generate-terraform.sh + run: sh .platform/scripts/generate-terraform.sh - name: Configure git credentials for module downloads if: hashFiles('app.yaml') != '' @@ -101,38 +97,12 @@ jobs: - name: Terraform Plan id: plan - working-directory: ${{ inputs.tf_root }} - run: | - set +e - terraform plan -out=tfplan -detailed-exitcode -no-color > plan-output.txt 2>&1 - PLAN_EXIT=$? - set -e - - cat plan-output.txt - - if [ "$PLAN_EXIT" = "1" ]; then - echo "Terraform plan failed" - exit 1 - elif [ "$PLAN_EXIT" = "2" ]; then - echo "has_changes=true" >> "$GITHUB_OUTPUT" - else - echo "has_changes=false" >> "$GITHUB_OUTPUT" - echo "No changes — downstream jobs will skip." - fi + run: .platform/scripts/run-plan.sh "${{ inputs.tf_root }}" - name: Upload plan to S3 id: upload if: steps.plan.outputs.has_changes == 'true' - working-directory: ${{ inputs.tf_root }} - run: | - PREFIX="plans/${{ github.repository }}/${{ github.run_id }}" - SHA256=$(sha256sum tfplan | awk '{print $1}') - - aws s3 cp tfplan "s3://${PLAN_BUCKET}/${PREFIX}/tfplan" - aws s3 cp plan-output.txt "s3://${PLAN_BUCKET}/${PREFIX}/plan-output.txt" - - echo "plan_key=${PREFIX}/tfplan" >> "$GITHUB_OUTPUT" - echo "plan_sha256=${SHA256}" >> "$GITHUB_OUTPUT" + run: .platform/scripts/upload-plan.sh "${{ inputs.tf_root }}" - name: Post plan to PR if: github.event_name == 'pull_request' diff --git a/CLAUDE.md b/CLAUDE.md index 1058442..3634e5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ Migration happens later per-app at developer's pace — apps move from old ALB/E | `.piano/app.yaml` | `app.yaml` (repo root) | | No Identity Center | IAM Identity Center + Google Workspace SAML | | No Cognito | Internal + External Cognito User Pools | -| No registry repo | `javaBin/registry` for app/team registration | +| No registry repo | `javaBin/registry` for team registration | | SNS topics: ai-platform-* | SNS topics: javabin-* | | SSM: /ai-platform/* | SSM: /javabin/* | @@ -100,6 +100,7 @@ Migration happens later per-app at developer's pace — apps move from old ALB/E | `docs/app-yaml-reference.md` | app.yaml schema and field reference | | `docs/bootstrap-runbook.md` | State backend bootstrap procedure | | `docs/org-runbook.md` | AWS Organizations setup procedure | +| `docs/cognito-google-setup.md` | Cognito + Google Workspace IdP setup | ### Terraform — Platform (CI-applied) ``` @@ -115,7 +116,7 @@ terraform/platform/ compute/ ECS cluster, ECR base config monitoring/ SNS, EventBridge, Config, GuardDuty, Security Hub lambdas/ slack-alert, cost-report, daily-cost-check, compliance-reporter, override-cleanup, team-provisioner - identity/ IAM Identity Center, Cognito pools (NOT YET IMPLEMENTED — empty dir) + identity/ Cognito user pools (internal + external). Identity Center is in terraform/org/ ``` ### Terraform — Org (human-applied, no CI) @@ -166,7 +167,8 @@ terraform/state/ ecs-deploy.yml ECS task definition update plan-review.yml LLM risk + cost analysis approve-override.yml Risk gate override (board members) - provision-app.yml App provisioning triggered from registry + provision-app.yml Team provisioning triggered from registry dispatch + commit-terraform.yml Commit generated TF files back to app repos ``` ### Lambda Functions @@ -177,15 +179,19 @@ terraform/state/ | `daily-cost-check` | Daily spike detection (silent if no spikes) | | `compliance-reporter` | Reports untagged resources to Slack (no auto-fix) | | `override-cleanup` | Hourly cleanup of stale SSM override tokens | -| `team-provisioner` | Stub — logs event and returns 200. Google/GitHub/Cognito sync not implemented | +| `team-provisioner` | Syncs Google Groups, GitHub teams, AWS Budgets from registry team YAML | ### Scripts | Script | What | |--------|------| | `scripts/bootstrap.sh` | One-time: create state bucket + lock tables | | `scripts/generate-terraform.sh` | CI: app.yaml → Terraform files | +| `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-block.py` | CI: post Slack notification when deploy is blocked | +| `scripts/notify-slack.py` | CI: generic Slack webhook notification | +| `scripts/check-risk-gate.sh` | CI: check risk level, consume override token or block | +| `scripts/run-plan.sh` | CI: terraform plan with exit code handling | +| `scripts/upload-plan.sh` | CI: upload plan artifact to S3 | ## Naming @@ -254,7 +260,7 @@ The SA JSON key is at `/javabin/platform/google-admin-sa`, the impersonation tar | 0a | AWS Discovery | **Done** | | 0b | Bootstrap State Backend | **Done** — S3 backend live | | 0c | Organizations + Permission Boundary | **Done** — org enabled, boundary deployed, SCP deferred | -| 1 | Identity (Google + Identity Center + Cognito) | **Partially done** — GCP SA with domain-wide delegation configured, GitHub App credentials in SSM. Identity Center and Cognito pools not deployed (`identity/` dir empty) | +| 1 | Identity (Google + Identity Center + Cognito) | **Partially done** — GCP SA with domain-wide delegation configured, GitHub App credentials in SSM. Cognito pool Terraform exists in `identity/` and is wired in `main.tf`, but not yet applied with Google IdP config. Identity Center lives in `terraform/org/`. | | 2a | Networking | **Deployed** — VPC, subnets, NAT | | 2b | Ingress | **Deployed** — ALB + ACM cert | | 2c | IAM / OIDC | **Deployed** — 4 CI roles + per-app roles | @@ -263,19 +269,17 @@ The SA JSON key is at `/javabin/platform/google-admin-sa`, the impersonation tar | 2f | Lambda Functions | **Deployed** — 5 working + team-provisioner (stub only) | | 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** — 13 reusable workflows | +| 3b | GitHub Actions Workflows | **Code done** — 14 reusable workflows | | 3c | app.yaml Schema + Generation | **Done** — generate-terraform.sh + docs | -| 3d | Registry Repo | **Partially working** — repo exists, provision workflow fails (missing GH_TOKEN) | -| 3e | javabin CLI | **Code done** — 3 commands in javaBin/javabin-cli | +| 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 | -| 4 | App Onboarding | **Blocked** — platform-test-app registered but CI failing (ECR repo not provisioned, tf-plan 404) | +| 4 | App Onboarding | **Partially working** — platform-test-app full pipeline passes (plan → review → apply → docker-build), ECS deploy fails on service stabilization | ### Known Issues -- **Registry → platform dispatch broken**: `provision-app.yml` fails because `GH_TOKEN` env var not set in workflow -- **platform-test-app CI failing**: ECR repo `platform-test-app` doesn't exist (never provisioned), tf-plan gets 404 on platform repo checkout -- **Team provisioner is a stub**: Lambda deployed but only logs. SSM credentials are ready (Google SA + GitHub App) — needs actual sync implementation -- **Identity module empty**: `terraform/platform/identity/` is an empty directory, not wired in `main.tf` -- **Cognito pools not deployed**: `cognito-app-client` module exists but has no pools to connect to +- **ECS deploy stabilization**: platform-test-app task registers but service fails health check — likely networking or port config +- **Cognito pools not yet applied**: `identity/` has Terraform wired in `main.tf`, but requires `google_client_id`/`google_client_secret`/`certificate_arn` variables +- **`registered_app_repos` manually managed**: Per-repo IAM roles require entries in this variable. No automated mechanism yet — add repos manually to `registered-apps.auto.tfvars` ## Agent Guidelines diff --git a/repos/app-template/.github/workflows/deploy.yml b/repos/app-template/.github/workflows/deploy.yml deleted file mode 100644 index d73ee1b..0000000 --- a/repos/app-template/.github/workflows/deploy.yml +++ /dev/null @@ -1,9 +0,0 @@ -name: Deploy -on: - push: - branches: [main, master] - pull_request: -jobs: - javabin: - uses: javaBin/platform/.github/workflows/javabin.yml@main - secrets: inherit diff --git a/repos/app-template/Dockerfile b/repos/app-template/Dockerfile deleted file mode 100644 index f2a9bbd..0000000 --- a/repos/app-template/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -# Multi-stage build — adjust base images for your runtime -FROM eclipse-temurin:21-jdk-alpine AS build -WORKDIR /app -COPY . . -RUN ./mvnw -q package -DskipTests - -FROM eclipse-temurin:21-jre-alpine -WORKDIR /app -COPY --from=build /app/target/*.jar app.jar -EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/repos/app-template/README.md b/repos/app-template/README.md deleted file mode 100644 index ba6df1f..0000000 --- a/repos/app-template/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# My Service - -A Javabin platform service. - -## Getting Started - -1. **Create from template**: Click "Use this template" on GitHub -2. **Register your app**: Open a PR to [javaBin/registry](https://github.com/javaBin/registry) adding `apps/your-service.yaml` -3. **Customize `app.yaml`**: Set your service name, team, compute, and routing -4. **Push to main**: The platform CI pipeline builds, plans, reviews, and deploys automatically - -## Platform Features - -- Automatic Docker build and ECR push -- Terraform infrastructure from `app.yaml` (S3, DynamoDB, SQS, Secrets Manager) -- LLM-powered plan review with risk gating -- ECS Fargate deployment with health checks -- CloudWatch alarms and cost monitoring - -## References - -- [app.yaml reference](https://github.com/javaBin/platform/blob/main/docs/app-yaml-reference.md) -- [Platform documentation](https://github.com/javaBin/platform) -- [Registry](https://github.com/javaBin/registry) diff --git a/repos/app-template/app.yaml b/repos/app-template/app.yaml deleted file mode 100644 index 11e3339..0000000 --- a/repos/app-template/app.yaml +++ /dev/null @@ -1,47 +0,0 @@ -# app.yaml — Javabin Platform Service Configuration -# Full reference: https://github.com/javaBin/platform/blob/main/docs/app-yaml-reference.md - -# Service name (required) — lowercase + hyphens, max 20 chars -name: my-service - -# Owning team (required) — must match a team in javaBin/registry -team: my-team - -# Container compute settings (optional, defaults shown) -compute: - cpu: 512 # vCPU units (256, 512, 1024, 2048, 4096) - memory: 1024 # MB (512-30720, must match cpu) - port: 8080 # container port your app listens on - desired_count: 1 # number of running tasks - health_check: /health - -# ALB routing (required for web services) -routing: - host: my-service.javazone.no # DNS name, must be under javazone.no - priority: 100 # unique across all apps (1-50000) - -# Infrastructure resources (optional) — auto-wired to your container -# resources: -# buckets: -# - name: data -# env: DATA_BUCKET -# databases: -# - name: cache -# hash_key: id -# env: CACHE_TABLE -# secrets: -# - name: api-key -# env: API_KEY -# queues: -# - name: jobs -# env: JOBS_QUEUE_URL - -# Cognito auth (optional) — internal, external, both, or none -# auth: internal - -# Custom environment variables -# environment: -# LOG_LEVEL: info - -# Monthly budget alert in NOK (default: 1000) -# budget_alert_nok: 500 diff --git a/repos/registry/.github/CODEOWNERS b/repos/registry/.github/CODEOWNERS deleted file mode 100644 index 9316235..0000000 --- a/repos/registry/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @javaBin/platform-owners diff --git a/repos/registry/.github/workflows/notify.yml b/repos/registry/.github/workflows/notify.yml deleted file mode 100644 index 5ecbb7a..0000000 --- a/repos/registry/.github/workflows/notify.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Notify Registration - -on: - pull_request: - types: [opened, reopened] - paths: - - 'apps/**' - - 'teams/**' - -permissions: - contents: read - -jobs: - notify: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - # Uses a repository secret rather than SSM — the registry repo does not have - # an IAM role to assume (ci-infra trusts only javaBin/platform). A dedicated - # javabin-ci-registry role can be added later if SSM access is needed. - - name: Post to Slack - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_RESOURCE_ALERTS_WEBHOOK }} - PR_URL: ${{ github.event.pull_request.html_url }} - PR_TITLE: ${{ github.event.pull_request.title }} - PR_AUTHOR: ${{ github.event.pull_request.user.login }} - run: | - python3 << 'PYEOF' - import json, os, urllib.request - webhook_url = os.environ.get("SLACK_WEBHOOK_URL", "") - if not webhook_url: - print("No SLACK_RESOURCE_ALERTS_WEBHOOK secret set, skipping notification") - exit(0) - pr_url = os.environ["PR_URL"] - pr_title = os.environ["PR_TITLE"] - pr_author = os.environ["PR_AUTHOR"] - payload = json.dumps({ - "blocks": [ - {"type": "header", "text": {"type": "plain_text", "text": "New Registry Request", "emoji": True}}, - {"type": "section", "text": {"type": "mrkdwn", "text": f"*{pr_title}*\nBy: {pr_author}"}}, - {"type": "section", "text": {"type": "mrkdwn", "text": f"<{pr_url}|Review PR>"}}, - ], - "text": f"New registry request: {pr_title}" - }).encode() - req = urllib.request.Request(webhook_url, data=payload, headers={"Content-Type": "application/json"}) - urllib.request.urlopen(req) - PYEOF diff --git a/repos/registry/.github/workflows/provision.yml b/repos/registry/.github/workflows/provision.yml deleted file mode 100644 index 23d24f3..0000000 --- a/repos/registry/.github/workflows/provision.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Provision - -on: - push: - branches: [main] - paths: - - 'apps/**' - - 'teams/**' - -permissions: - contents: read - -jobs: - provision: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Determine changed files - id: changes - run: | - CHANGED_APPS=$(git diff --name-only HEAD~1 HEAD -- 'apps/*.yaml' | xargs -I{} basename {} .yaml | tr '\n' ',' | sed 's/,$//') - CHANGED_TEAMS=$(git diff --name-only HEAD~1 HEAD -- 'teams/*.yaml' | xargs -I{} basename {} .yaml | tr '\n' ',' | sed 's/,$//') - echo "apps=${CHANGED_APPS}" >> "$GITHUB_OUTPUT" - echo "teams=${CHANGED_TEAMS}" >> "$GITHUB_OUTPUT" - echo "Changed apps: ${CHANGED_APPS}" - echo "Changed teams: ${CHANGED_TEAMS}" - - - name: Trigger platform provisioning - if: steps.changes.outputs.apps != '' || steps.changes.outputs.teams != '' - env: - GH_TOKEN: ${{ secrets.PLATFORM_DISPATCH_TOKEN }} - run: | - gh api repos/javaBin/platform/dispatches \ - -f event_type=registry-update \ - -f client_payload="{\"apps\":\"${{ steps.changes.outputs.apps }}\",\"teams\":\"${{ steps.changes.outputs.teams }}\"}" diff --git a/repos/registry/.github/workflows/validate.yml b/repos/registry/.github/workflows/validate.yml deleted file mode 100644 index 34f6407..0000000 --- a/repos/registry/.github/workflows/validate.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Validate Registration - -on: - pull_request: - paths: - - 'apps/**' - - 'teams/**' - -permissions: - contents: read - pull-requests: write - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Validate YAML and references - run: | - ERRORS=0 - - # Validate app registrations - for f in $(git diff --name-only --diff-filter=ACMR origin/main...HEAD -- 'apps/*.yaml'); do - echo "Validating $f" - APP_NAME=$(grep -m1 '^name:' "$f" | awk '{print $2}') - APP_TEAM=$(grep -m1 '^team:' "$f" | awk '{print $2}') - APP_REPO=$(grep -m1 '^repo:' "$f" | awk '{print $2}') - - if [ -z "$APP_NAME" ]; then - echo "::error file=$f::Missing required field: name" - ERRORS=$((ERRORS + 1)) - fi - - if [ -z "$APP_TEAM" ]; then - echo "::error file=$f::Missing required field: team" - ERRORS=$((ERRORS + 1)) - elif [ ! -f "teams/${APP_TEAM}.yaml" ]; then - echo "::error file=$f::Team '${APP_TEAM}' not found in teams/" - ERRORS=$((ERRORS + 1)) - fi - - if [ -z "$APP_REPO" ]; then - echo "::error file=$f::Missing required field: repo" - ERRORS=$((ERRORS + 1)) - fi - done - - # Validate team definitions - for f in $(git diff --name-only --diff-filter=ACMR origin/main...HEAD -- 'teams/*.yaml'); do - echo "Validating $f" - TEAM_NAME=$(grep -m1 '^name:' "$f" | awk '{print $2}') - - if [ -z "$TEAM_NAME" ]; then - echo "::error file=$f::Missing required field: name" - ERRORS=$((ERRORS + 1)) - fi - done - - if [ "$ERRORS" -gt 0 ]; then - echo "Validation failed with $ERRORS error(s)" - exit 1 - fi - echo "All validations passed" - - - name: Post result to PR - if: always() - uses: actions/github-script@v7 - with: - script: | - const status = '${{ job.status }}' === 'success' ? 'passed' : 'failed'; - const emoji = status === 'passed' ? ':white_check_mark:' : ':x:'; - await github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: `## Registry Validation ${emoji}\n\nValidation **${status}**.` - }); diff --git a/repos/registry/README.md b/repos/registry/README.md deleted file mode 100644 index 8c49778..0000000 --- a/repos/registry/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# Javabin Registry - -Central registry for app and team registration. Merging a PR here triggers infrastructure provisioning in the platform. - -## Registering an App - -1. Create `apps/your-service.yaml`: -```yaml -name: your-service -team: your-team -repo: javaBin/your-service -``` - -2. Open a PR. CI validates the YAML and checks that the team exists. -3. A platform owner reviews and merges. -4. Merge triggers IAM role creation, ECR repo, and budget setup. - -## Registering a Team - -1. Create `teams/your-team.yaml`: -```yaml -name: your-team -description: What your team works on -google_group: team-yourteam@java.no -members: - - username1 - - username2 -repos: - - repo-name -aws_budget_nok: 3000 -``` - -2. Open a PR and get it reviewed by a platform owner. - -## App Registration Schema - -| Field | Required | Description | -|-------|----------|-------------| -| `name` | Yes | Service name, matches repo name | -| `team` | Yes | Team name, must exist in `teams/` | -| `repo` | Yes | Full repo path, e.g. `javaBin/cake-redux` | -| `existing` | No | Set `true` for pre-existing apps migrating to platform | - -## Team Definition Schema - -| Field | Required | Description | -|-------|----------|-------------| -| `name` | Yes | Team identifier | -| `description` | Yes | What the team does | -| `google_group` | No | Google Workspace group email | -| `members` | Yes | List of member usernames | -| `repos` | Yes | List of repo names this team owns | -| `aws_budget_nok` | No | Team-level monthly budget in NOK | - -## How It Works - -``` -Developer opens PR ──> CI validates YAML + team refs - ──> Slack notification to #javabin-infra-alerts -Platform owner merges ──> repository_dispatch to javaBin/platform - ──> Platform creates IAM roles, ECR, Budget -``` - -The registry is the IAM gate: unregistered repos have no IAM role to assume, so their CI pipelines cannot deploy. - -## Access - -- Private repo, visible to javaBin org members -- Any org member can open a registration PR -- Merges require review from `@javaBin/platform-owners` diff --git a/repos/registry/apps/.gitkeep b/repos/registry/apps/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/repos/registry/teams/platform-owners.yaml b/repos/registry/teams/platform-owners.yaml deleted file mode 100644 index ecae489..0000000 --- a/repos/registry/teams/platform-owners.yaml +++ /dev/null @@ -1,8 +0,0 @@ -name: platform-owners -description: Platform infrastructure team -google_group: team-platform@java.no -members: - - alexander.amiri -repos: - - platform -aws_budget_nok: 5000 diff --git a/scripts/check-risk-block.sh b/scripts/check-risk-block.sh new file mode 100644 index 0000000..12ae7be --- /dev/null +++ b/scripts/check-risk-block.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Block apply if risk is HIGH or FAILED. Notify Slack with override link. +# +# Usage: check-risk-block.sh +# +# Exits 0 if safe to apply, 1 if blocked. + +set -e + +RISK="$1" +SSM_PARAM="$2" +OVERRIDE_URL="$3" +SCRIPT_DIR=$(dirname "$0") + +echo "LLM review risk: ${RISK}" + +if [ "$RISK" != "HIGH" ] && [ "$RISK" != "FAILED" ] && [ -n "$RISK" ]; then + exit 0 +fi + +echo "Auto-apply blocked (risk=${RISK})." + +export SSM_WEBHOOK_PARAM="$SSM_PARAM" +sh "$SCRIPT_DIR/notify-slack.sh" \ + "Deploy Blocked — ${RISK} Risk" \ + "*Repo:* ${GITHUB_REPOSITORY}\n*SHA:* \`$(echo "$GITHUB_SHA" | cut -c1-8)\`\n*Actor:* ${GITHUB_ACTOR}" \ + "$OVERRIDE_URL" \ + "Approve Override" || true + +exit 1 diff --git a/scripts/check-risk-gate.sh b/scripts/check-risk-gate.sh new file mode 100644 index 0000000..f082e74 --- /dev/null +++ b/scripts/check-risk-gate.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Check risk level and block apply if HIGH without an override token. +# +# Usage: check-risk-gate.sh +# +# Exits 0 if apply should proceed, 1 if blocked. +# On block: consumes override token from SSM or posts Slack alert and fails. + +set -e + +RISK="$1" +REPO="$2" +SHA="$3" +SSM_PARAM="$4" +SCRIPT_DIR=$(dirname "$0") + +echo "LLM review risk: ${RISK}" + +if [ "$RISK" = "LOW" ] || [ "$RISK" = "MEDIUM" ]; then + exit 0 +fi + +echo "Checking for override token..." +OVERRIDE_KEY="/javabin/platform-overrides/${REPO}/${SHA}" + +set +e +aws ssm get-parameter --name "$OVERRIDE_KEY" --query Parameter.Value --output text > /dev/null 2>&1 +OVERRIDE_EXISTS=$? +set -e + +if [ "$OVERRIDE_EXISTS" = "0" ]; then + echo "Override token found — applying despite ${RISK} risk." + aws ssm delete-parameter --name "$OVERRIDE_KEY" 2>/dev/null || true + exit 0 +fi + +echo "Auto-apply blocked (risk=${RISK}, no override token)." + +RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" +OVERRIDE_URL="https://github.com/javaBin/platform/actions/workflows/approve-override.yml" + +export SSM_WEBHOOK_PARAM="$SSM_PARAM" +sh "$SCRIPT_DIR/notify-slack.sh" \ + "Terraform Apply Blocked" \ + "*Repo:* ${GITHUB_REPOSITORY}\n*Reason:* ${RISK} risk — override required\n<${RUN_URL}|View Workflow> | <${OVERRIDE_URL}|Approve Override>" || true + +exit 1 diff --git a/scripts/commit-generated-tf.sh b/scripts/commit-generated-tf.sh new file mode 100644 index 0000000..63372fb --- /dev/null +++ b/scripts/commit-generated-tf.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Commit and push generated Terraform files with [skip ci]. +# +# Usage: commit-generated-tf.sh +# +# Does nothing if there are no changes to commit. + +set -e + +TF_ROOT="$1" +BRANCH="$2" + +git config user.name "javabin-platform[bot]" +git config user.email "platform@javazone.no" + +git add "$TF_ROOT/" + +if git diff --cached --quiet; then + echo "Generated Terraform files are already up to date." + exit 0 +fi + +git commit -m "[skip ci] Update generated Terraform from app.yaml" +git push origin "HEAD:${BRANCH}" +echo "Committed generated Terraform files." diff --git a/scripts/drift-check.sh b/scripts/drift-check.sh new file mode 100644 index 0000000..aaf2e89 --- /dev/null +++ b/scripts/drift-check.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Run terraform plan and detect drift. Notify Slack if drift found. +# +# Usage: drift-check.sh +# +# Must be run from the terraform root directory. + +set -e + +SSM_PARAM="$1" +RUN_URL="$2" +SCRIPT_DIR=$(dirname "$0") + +set +e +terraform plan -detailed-exitcode -no-color -lock-timeout=5m > drift.txt 2>&1 +EXIT_CODE=$? +set -e + +if [ "$EXIT_CODE" = "2" ]; then + echo "Drift detected!" + export SSM_WEBHOOK_PARAM="$SSM_PARAM" + sh "$SCRIPT_DIR/notify-slack.sh" \ + "Terraform Drift Detected" \ + "Someone made changes outside of Terraform." \ + "$RUN_URL" \ + "View Full Plan" + exit 1 +elif [ "$EXIT_CODE" = "1" ]; then + echo "Terraform plan failed during drift detection." + exit 1 +else + echo "No drift detected." +fi diff --git a/scripts/ecs-deploy.sh b/scripts/ecs-deploy.sh new file mode 100644 index 0000000..3470aec --- /dev/null +++ b/scripts/ecs-deploy.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Deploy a new container image to an ECS service. +# +# Usage: ecs-deploy.sh +# +# Env: CLUSTER, SERVICE, ECR_REPO, IMAGE_TAG, ACCOUNT_ID, REGION + +set -e + +SCRIPT_DIR=$(dirname "$0") +export ECR_URI="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/${ECR_REPO}:${IMAGE_TAG}" + +TASK_DEF_ARN=$(aws ecs describe-services \ + --cluster "$CLUSTER" --services "$SERVICE" \ + --query 'services[0].taskDefinition' --output text) + +aws ecs describe-task-definition --task-definition "$TASK_DEF_ARN" \ + --query 'taskDefinition' > task-def.json + +sh "$SCRIPT_DIR/update-task-def.sh" task-def.json task-def-new.json + +NEW_ARN=$(aws ecs register-task-definition \ + --cli-input-json file://task-def-new.json \ + --query 'taskDefinition.taskDefinitionArn' --output text) +echo "New task definition: $NEW_ARN" + +aws ecs update-service \ + --cluster "$CLUSTER" --service "$SERVICE" \ + --task-definition "$NEW_ARN" > /dev/null + +echo "Waiting for service to stabilize..." +aws ecs wait services-stable --cluster "$CLUSTER" --services "$SERVICE" +echo "Deployment complete." diff --git a/scripts/extract-review-risk.sh b/scripts/extract-review-risk.sh new file mode 100644 index 0000000..36b3eb1 --- /dev/null +++ b/scripts/extract-review-risk.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Run the LLM review script and extract the risk level. +# +# Usage: extract-review-risk.sh +# +# Outputs to GITHUB_OUTPUT: risk_level +# Note: review-plan.py requires Python (Bedrock SDK), so Python is needed here. + +set -e + +REVIEW_SCRIPT="$1" +PLAN_FILE="$2" + +python3 "$REVIEW_SCRIPT" "$PLAN_FILE" 2>&1 | tee review-output.txt || true + +if [ -f review-result.json ]; then + RISK=$(jq -r '.risk // "FAILED"' review-result.json) +else + RISK="FAILED" +fi + +echo "risk_level=${RISK}" >> "$GITHUB_OUTPUT" +echo "LLM review risk: ${RISK}" diff --git a/scripts/notify-block.py b/scripts/notify-block.py deleted file mode 100755 index d5f416a..0000000 --- a/scripts/notify-block.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -"""Post a deploy-blocked notification to Slack. - -Usage: - python3 scripts/notify-block.py - -Required environment variables: - WEBHOOK_URL - Slack webhook URL - RISK - Risk level (HIGH, FAILED, etc.) - GITHUB_REPOSITORY - repo name - GITHUB_SHA - commit SHA - GITHUB_ACTOR - who triggered the run - OVERRIDE_URL - link to approve-override workflow -""" - -import json -import os -import sys -import urllib.request - - -def main(): - webhook = os.environ.get("WEBHOOK_URL", "") - if not webhook: - print("No WEBHOOK_URL set, skipping Slack notification") - return - - risk = os.environ.get("RISK", "UNKNOWN") - repo = os.environ.get("GITHUB_REPOSITORY", "unknown") - sha = os.environ.get("GITHUB_SHA", "unknown")[:8] - actor = os.environ.get("GITHUB_ACTOR", "unknown") - override_url = os.environ.get("OVERRIDE_URL", "") - - title = f"Deploy Blocked — {risk} Risk" - - blocks = [ - {"type": "header", "text": {"type": "plain_text", "text": title}}, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"*Repo:* {repo}\n*SHA:* `{sha}`\n*Actor:* {actor}", - }, - }, - ] - - if override_url: - blocks.append( - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"<{override_url}|Approve Override>"}, - } - ) - - payload = json.dumps({"blocks": blocks, "text": f"Deploy blocked for {repo}"}).encode() - req = urllib.request.Request(webhook, data=payload, headers={"Content-Type": "application/json"}) - try: - urllib.request.urlopen(req) - print(f"Slack notification sent: {title}") - except Exception as e: - print(f"Failed to send Slack notification: {e}", file=sys.stderr) - - -if __name__ == "__main__": - main() diff --git a/scripts/notify-high-risk.sh b/scripts/notify-high-risk.sh new file mode 100644 index 0000000..36e110a --- /dev/null +++ b/scripts/notify-high-risk.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Notify Slack about a HIGH risk plan review with override link. +# +# Usage: notify-high-risk.sh +# +# Reads review-result.json from current directory. + +set -e + +SSM_PARAM="$1" +OVERRIDE_URL="$2" +SCRIPT_DIR=$(dirname "$0") + +SUMMARY=$(jq -r '.summary // "Review failed"' review-result.json 2>/dev/null || echo "Review failed") + +export SSM_WEBHOOK_PARAM="$SSM_PARAM" +sh "$SCRIPT_DIR/notify-slack.sh" \ + "Deploy Blocked — HIGH Risk Plan" \ + "*Summary:* ${SUMMARY}" \ + "$OVERRIDE_URL" \ + "Approve Override" diff --git a/scripts/notify-slack.sh b/scripts/notify-slack.sh new file mode 100644 index 0000000..a5254bf --- /dev/null +++ b/scripts/notify-slack.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# Post a Block Kit message to Slack via incoming webhook. +# +# Usage: notify-slack.sh <message> [url] [url_label] +# +# The webhook URL is resolved in order: +# 1. SLACK_WEBHOOK_URL env var (if set) +# 2. SSM_WEBHOOK_PARAM env var → fetched from AWS SSM +# +# If neither is set, the notification is silently skipped. + +set -e + +TITLE="$1" +MESSAGE="$2" +URL="${3:-}" +URL_LABEL="${4:-View}" + +# Resolve webhook URL +if [ -z "$SLACK_WEBHOOK_URL" ] && [ -n "$SSM_WEBHOOK_PARAM" ]; then + SLACK_WEBHOOK_URL=$(aws ssm get-parameter \ + --name "$SSM_WEBHOOK_PARAM" \ + --with-decryption --query Parameter.Value --output text 2>/dev/null || echo "") +fi + +if [ -z "$SLACK_WEBHOOK_URL" ]; then + echo "No webhook URL available — skipping Slack notification." + exit 0 +fi + +# Build blocks JSON +URL_BLOCK="" +if [ -n "$URL" ]; then + URL_BLOCK=",{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\"<${URL}|${URL_LABEL}>\"}}" +fi + +PAYLOAD=$(cat <<EOF +{ + "blocks": [ + {"type":"header","text":{"type":"plain_text","text":"${TITLE}","emoji":true}}, + {"type":"section","text":{"type":"mrkdwn","text":"${MESSAGE}"}} + ${URL_BLOCK} + ], + "text": "${TITLE}" +} +EOF +) + +curl -s -X POST -H "Content-Type: application/json" -d "$PAYLOAD" "$SLACK_WEBHOOK_URL" > /dev/null +echo "Slack notification sent: ${TITLE}" diff --git a/scripts/provision-teams.sh b/scripts/provision-teams.sh new file mode 100644 index 0000000..b9c3515 --- /dev/null +++ b/scripts/provision-teams.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Invoke the team-provisioner Lambda with team YAML files. +# The Lambda reconciles Google Groups, GitHub teams, and AWS Budgets. +# +# Usage: provision-teams.sh <team1.yaml> [team2.yaml ...] +# +# Requires: aws CLI, yq (pre-installed on GitHub runners) + +set -e + +if [ $# -eq 0 ]; then + echo "Usage: provision-teams.sh <team1.yaml> [team2.yaml ...]" >&2 + exit 1 +fi + +TEAMS="[]" +for file in "$@"; do + [ -f "$file" ] || continue + TEAM=$(yq -o json "$file") + NAME=$(echo "$TEAM" | yq -r '.name') + MEMBERS=$(echo "$TEAM" | yq '.members | length') + echo " ${NAME}: ${MEMBERS} member(s)" + TEAMS=$(echo "$TEAMS" | yq -o json ". + [${TEAM}]") +done + +COUNT=$(echo "$TEAMS" | yq 'length') +if [ "$COUNT" -eq 0 ]; then + echo "No valid team definitions — skipping." + exit 0 +fi + +echo "Invoking javabin-team-provisioner with ${COUNT} team(s)..." +PAYLOAD=$(echo "$TEAMS" | yq -o json '{"teams": .}') + +aws lambda invoke \ + --function-name javabin-team-provisioner \ + --payload "$PAYLOAD" \ + --cli-binary-format raw-in-base64-out \ + /tmp/lambda-response.json + +cat /tmp/lambda-response.json diff --git a/scripts/run-plan.sh b/scripts/run-plan.sh new file mode 100755 index 0000000..2e8cdfc --- /dev/null +++ b/scripts/run-plan.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Run terraform plan with detailed exit code handling. +# +# Usage: +# scripts/run-plan.sh <working-directory> [extra-args...] +# +# Exit codes from terraform plan -detailed-exitcode: +# 0 = no changes +# 1 = error +# 2 = changes present +# +# Outputs (written to $GITHUB_OUTPUT if set): +# has_changes=true|false +# +# The plan binary is written to <working-directory>/tfplan +# Human-readable output is written to <working-directory>/plan-output.txt + +set -uo pipefail + +WORK_DIR="${1:?Usage: run-plan.sh <working-directory> [extra-args...]}" +shift +EXTRA_ARGS=("$@") + +cd "$WORK_DIR" + +set +e +terraform plan -out=tfplan -detailed-exitcode -no-color "${EXTRA_ARGS[@]}" > plan-output.txt 2>&1 +PLAN_EXIT=$? +set -e + +cat plan-output.txt + +if [ "$PLAN_EXIT" = "1" ]; then + echo "Terraform plan failed" + exit 1 +elif [ "$PLAN_EXIT" = "2" ]; then + echo "has_changes=true" >> "${GITHUB_OUTPUT:-/dev/null}" +else + echo "has_changes=false" >> "${GITHUB_OUTPUT:-/dev/null}" + echo "No changes — downstream jobs will skip." +fi diff --git a/scripts/update-task-def.sh b/scripts/update-task-def.sh new file mode 100644 index 0000000..137a924 --- /dev/null +++ b/scripts/update-task-def.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Update an ECS task definition with a new container image. +# +# Usage: update-task-def.sh <input.json> <output.json> +# +# Env: ECR_URI — full image URI with tag + +set -e + +INPUT="$1" +OUTPUT="$2" + +if [ -z "$ECR_URI" ]; then + echo "ECR_URI is required" >&2 + exit 1 +fi + +jq --arg uri "$ECR_URI" ' + .containerDefinitions[0].image = $uri | + del(.taskDefinitionArn, .revision, .status, .requiresAttributes, + .compatibilities, .registeredAt, .registeredBy, .deregisteredAt) +' "$INPUT" > "$OUTPUT" + +echo "Updated task definition: $OUTPUT" diff --git a/scripts/upload-plan.sh b/scripts/upload-plan.sh new file mode 100755 index 0000000..d122851 --- /dev/null +++ b/scripts/upload-plan.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Upload terraform plan + output to S3 and set GitHub outputs. +# +# Usage: +# scripts/upload-plan.sh <working-directory> +# +# Required environment variables: +# PLAN_BUCKET - S3 bucket for plan artifacts +# GITHUB_REPOSITORY - e.g. javaBin/platform +# GITHUB_RUN_ID - workflow run ID +# +# Outputs (written to $GITHUB_OUTPUT): +# plan_key - S3 key for the plan binary +# plan_text_key - S3 key for the plan text output +# plan_sha256 - SHA256 hash of the plan binary + +set -euo pipefail + +WORK_DIR="${1:?Usage: upload-plan.sh <working-directory>}" + +cd "$WORK_DIR" + +PREFIX="plans/${GITHUB_REPOSITORY}/${GITHUB_RUN_ID}" +SHA256=$(sha256sum tfplan | awk '{print $1}') + +aws s3 cp tfplan "s3://${PLAN_BUCKET}/${PREFIX}/tfplan" +aws s3 cp plan-output.txt "s3://${PLAN_BUCKET}/${PREFIX}/plan-output.txt" + +{ + echo "plan_key=${PREFIX}/tfplan" + echo "plan_text_key=${PREFIX}/plan-output.txt" + echo "plan_sha256=${SHA256}" +} >> "${GITHUB_OUTPUT:-/dev/null}" diff --git a/scripts/verify-plan.sh b/scripts/verify-plan.sh new file mode 100644 index 0000000..d8e808b --- /dev/null +++ b/scripts/verify-plan.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Verify a Terraform plan file's SHA256 hash. +# +# Usage: verify-plan.sh <plan_file> <expected_sha256> + +set -e + +PLAN_FILE="$1" +EXPECTED="$2" + +ACTUAL=$(sha256sum "$PLAN_FILE" | awk '{print $1}') + +if [ "$EXPECTED" != "$ACTUAL" ]; then + echo "Plan SHA256 mismatch! Expected: ${EXPECTED}, Got: ${ACTUAL}" + exit 1 +fi + +echo "Plan integrity verified." diff --git a/scripts/write-override-token.sh b/scripts/write-override-token.sh new file mode 100644 index 0000000..27e4123 --- /dev/null +++ b/scripts/write-override-token.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Write a risk gate override token to SSM. +# +# Usage: write-override-token.sh <repo> <sha> <actor> <reason> + +set -e + +REPO="$1" +SHA="$2" +ACTOR="$3" +REASON="$4" + +PARAM_NAME="/javabin/platform-overrides/${REPO}/${SHA}" +VALUE=$(jq -n \ + --arg by "$ACTOR" \ + --arg reason "$REASON" \ + --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg run "${GITHUB_RUN_ID:-}" \ + '{approved_by: $by, reason: $reason, approved_at: $at, run_id: $run}') + +aws ssm put-parameter \ + --name "$PARAM_NAME" \ + --type String \ + --value "$VALUE" \ + --overwrite + +echo "Override token written: ${PARAM_NAME}" diff --git a/terraform/platform/iam/main.tf b/terraform/platform/iam/main.tf index ed8acc9..96f377a 100644 --- a/terraform/platform/iam/main.tf +++ b/terraform/platform/iam/main.tf @@ -525,6 +525,59 @@ resource "aws_iam_role_policy" "ci_override_approver_ssm" { }) } +################################################################################ +# 5. javabin-ci-registry — Registry repo team provisioning +# +# Trust: GitHub OIDC pinned to javaBin/registry on main branch. +# Permissions: ONLY lambda:InvokeFunction on the team-provisioner Lambda. +# This lets the registry invoke the Lambda directly on merge, without +# dispatching through the platform repo. +################################################################################ + +resource "aws_iam_role" "ci_registry" { + name = "${var.project}-ci-registry" + permissions_boundary = aws_iam_policy.developer_boundary.arn + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Federated = data.aws_iam_openid_connect_provider.github.arn + } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" + } + StringLike = { + "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/registry:ref:refs/heads/main" + } + } + } + ] + }) + + tags = { + Name = "${var.project}-ci-registry" + } +} + +resource "aws_iam_role_policy" "ci_registry_lambda" { + name = "invoke-team-provisioner" + role = aws_iam_role.ci_registry.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "lambda:InvokeFunction" + Resource = "arn:aws:lambda:${var.region}:${var.aws_account_id}:function:${var.project}-team-provisioner" + }] + }) +} + ################################################################################ # ECS Execution Role — pulls images, writes logs, reads secrets # diff --git a/terraform/platform/iam/outputs.tf b/terraform/platform/iam/outputs.tf index a35b04a..1109988 100644 --- a/terraform/platform/iam/outputs.tf +++ b/terraform/platform/iam/outputs.tf @@ -23,6 +23,11 @@ output "ci_override_approver_role_arn" { value = aws_iam_role.ci_override_approver.arn } +output "ci_registry_role_arn" { + description = "ARN of the registry CI role" + value = aws_iam_role.ci_registry.arn +} + output "github_oidc_provider_arn" { description = "ARN of the GitHub OIDC provider (data source)" value = data.aws_iam_openid_connect_provider.github.arn