Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .github/workflows/detect.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@ on:
has_eb:
description: ".elasticbeanstalk/ directory exists"
value: ${{ jobs.detect.outputs.has_eb }}
has_cdk:
description: "cdk.json exists"
value: ${{ jobs.detect.outputs.has_cdk }}
app_name:
description: "App name from app.yaml (empty if no app.yaml)"
value: ${{ jobs.detect.outputs.app_name }}
Expand All @@ -39,7 +36,6 @@ jobs:
has_maven: ${{ steps.check.outputs.has_maven }}
has_pnpm: ${{ steps.check.outputs.has_pnpm }}
has_eb: ${{ steps.check.outputs.has_eb }}
has_cdk: ${{ steps.check.outputs.has_cdk }}
app_name: ${{ steps.check.outputs.app_name }}
steps:
- uses: actions/checkout@v6
Expand All @@ -53,7 +49,6 @@ jobs:
echo "has_maven=$(test -f pom.xml && echo true || echo false)" >> "$GITHUB_OUTPUT"
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
echo "app_name=$(grep -m1 '^name:' app.yaml | awk '{print $2}' | tr -d '\"'"'")" >> "$GITHUB_OUTPUT"
else
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/platform-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ on:
- '.github/workflows/**'
pull_request:
schedule:
# Drift detection — Monday 06:00 UTC
# Drift detection — Monday 06:00 UTC (runs drift job only)
- cron: '0 6 * * 1'
workflow_dispatch:
# Manual trigger — useful for syncing new repos immediately

permissions:
id-token: write
Expand Down Expand Up @@ -71,6 +73,12 @@ jobs:
aws-region: ${{ env.AWS_REGION }}
role-session-name: javabin-platform-plan-${{ github.run_id }}

- name: Sync registered repos from GitHub teams
if: steps.changes.outputs.has_infra_changes == 'true' && github.ref == 'refs/heads/main'
env:
GH_TOKEN: ${{ github.token }}
run: python3 scripts/sync-registered-repos.py "${{ env.TF_ROOT }}/registered-apps.auto.tfvars" || echo "Sync skipped — will use existing tfvars"

- name: Terraform Init
if: steps.changes.outputs.has_infra_changes == 'true'
working-directory: ${{ env.TF_ROOT }}
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/tf-plan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ jobs:
path: .platform
sparse-checkout: scripts

- name: Ensure Terraform boilerplate
env:
AWS_ACCOUNT_ID: ${{ inputs.aws_account_id }}
AWS_REGION: ${{ inputs.aws_region || 'eu-central-1' }}
GH_TOKEN: ${{ github.token }}
run: sh .platform/scripts/ensure-tf-boilerplate.sh "${{ inputs.tf_root }}"

- name: Terraform Init
working-directory: ${{ inputs.tf_root }}
run: terraform init -input=false
Expand Down
69 changes: 69 additions & 0 deletions scripts/ensure-tf-boilerplate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/bin/sh
# Auto-generate backend.tf and providers.tf for repos without app.yaml.
#
# If these files already exist (committed by the developer or generated by
# expand-modules.py), they are NOT overwritten. This is a safety net for
# repos that bring their own Terraform without using app.yaml.
#
# Usage: ensure-tf-boilerplate.sh <tf_root>
#
# Env: AWS_ACCOUNT_ID, AWS_REGION (default eu-central-1)
# Uses: GITHUB_REPOSITORY (org/repo), gh CLI for team resolution
#
# Generated files include default_tags with project, team, and managed-by
# so that ALL Terraform-created resources are automatically tagged —
# even if the developer doesn't add tags to their own resources.

set -e

TF_ROOT="${1:?Usage: ensure-tf-boilerplate.sh <tf_root>}"
ACCOUNT_ID="${AWS_ACCOUNT_ID:?AWS_ACCOUNT_ID required}"
REGION="${AWS_REGION:-eu-central-1}"
REPO_NAME="${GITHUB_REPOSITORY##*/}"
PROJECT="javabin"

# Resolve team from GitHub API (first non-platform team the repo belongs to)
TEAM=""
if command -v gh >/dev/null 2>&1; then
TEAM=$(gh api "/repos/${GITHUB_REPOSITORY}/teams" \
--jq '[.[] | select(.slug != "platform-owners")] | .[0].slug // ""' 2>/dev/null || true)
fi
[ -z "$TEAM" ] && TEAM="unknown"

# --- Generate backend.tf if missing ---
if [ ! -f "$TF_ROOT/backend.tf" ]; then
cat > "$TF_ROOT/backend.tf" <<EOF
# Auto-generated by platform CI — commit your own backend.tf to override
terraform {
backend "s3" {
bucket = "${PROJECT}-terraform-state-${ACCOUNT_ID}"
key = "apps/${REPO_NAME}/terraform.tfstate"
region = "${REGION}"
dynamodb_table = "${PROJECT}-terraform-app-locks"
encrypt = true
}
}
EOF
echo "Generated backend.tf (state key: apps/${REPO_NAME})"
fi

# --- Generate providers.tf if missing ---
if [ ! -f "$TF_ROOT/providers.tf" ]; then
cat > "$TF_ROOT/providers.tf" <<EOF
# Auto-generated by platform CI — commit your own providers.tf to override
# default_tags ensures ALL resources are tagged for ABAC, cost attribution,
# and compliance. Remove tags at your own risk — IAM will block untagged creates.
provider "aws" {
region = "${REGION}"

default_tags {
tags = {
project = "${REPO_NAME}"
team = "${TEAM}"
managed-by = "terraform"
}
}
}
EOF
echo "Generated providers.tf (project=${REPO_NAME}, team=${TEAM})"
fi
125 changes: 125 additions & 0 deletions scripts/sync-registered-repos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Sync registered app repos from GitHub team memberships.

Queries the GitHub API for all teams in the javaBin org, collects their repos,
and writes registered-apps.auto.tfvars. This replaces the manual list.

Source of truth: GitHub team → repo membership.
Teams add repos via GitHub UI. This script syncs that to Terraform.

Usage: sync-registered-repos.py <tfvars-output-path>

Requires: GITHUB_TOKEN env var (with org:read scope) or GitHub App credentials
via GH_APP_ID + GH_APP_KEY env vars (read from SSM if not set).
"""

import json
import logging
import os
import sys
import urllib.request

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(message)s")

GITHUB_ORG = os.environ.get("GITHUB_ORG", "javaBin")
GITHUB_API = "https://api.github.com"

# Repos that are platform infrastructure, not apps
EXCLUDED_REPOS = {"platform", "registry", ".github"}


def github_get(path, token):
"""Paginated GitHub API GET."""
results = []
url = f"{GITHUB_API}{path}"
while url:
req = urllib.request.Request(url)
req.add_header("Authorization", f"token {token}")
req.add_header("Accept", "application/vnd.github+json")
with urllib.request.urlopen(req) as resp:
results.extend(json.loads(resp.read()))
# Follow pagination
link = resp.headers.get("Link", "")
url = None
for part in link.split(","):
if 'rel="next"' in part:
url = part.split("<")[1].split(">")[0]
return results


def get_team_repos(token):
"""Get all repos across all org teams, excluding platform repos."""
teams = github_get(f"/orgs/{GITHUB_ORG}/teams", token)
logger.info("Found %d teams in %s", len(teams), GITHUB_ORG)

repo_set = set()
for team in teams:
slug = team["slug"]
repos = github_get(f"/orgs/{GITHUB_ORG}/teams/{slug}/repos", token)
team_repo_names = {r["name"] for r in repos} - EXCLUDED_REPOS
if team_repo_names:
logger.info(" %s: %s", slug, ", ".join(sorted(team_repo_names)))
repo_set.update(team_repo_names)

return sorted(repo_set)


def write_tfvars(repos, output_path):
"""Write the registered-apps.auto.tfvars file."""
lines = [
"# GENERATED by sync-registered-repos.py — do not edit manually",
"# Source of truth: GitHub team repo memberships in javaBin org",
f"# Last synced: {__import__('datetime').datetime.now(__import__('datetime').timezone.utc).isoformat()}",
"",
"registered_app_repos = [",
]
for repo in repos:
lines.append(f' "{repo}",')
lines.append("]")
lines.append("")

content = "\n".join(lines)

# Only write if changed
existing = ""
if os.path.exists(output_path):
with open(output_path) as f:
existing = f.read()

# Compare ignoring the timestamp line
def strip_timestamp(s):
return "\n".join(l for l in s.splitlines() if not l.startswith("# Last synced:"))

if strip_timestamp(content) == strip_timestamp(existing):
logger.info("No changes to registered repos")
return False

with open(output_path, "w") as f:
f.write(content)
logger.info("Wrote %d repos to %s", len(repos), output_path)
return True


def main():
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} <tfvars-output-path>", file=sys.stderr)
sys.exit(1)

output_path = sys.argv[1]
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if not token:
print("GITHUB_TOKEN or GH_TOKEN required", file=sys.stderr)
sys.exit(1)

repos = get_team_repos(token)
logger.info("Total registered repos: %d", len(repos))

changed = write_tfvars(repos, output_path)
if changed:
logger.info("registered-apps.auto.tfvars updated")
sys.exit(0)


if __name__ == "__main__":
main()