diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a15cd18d..91f40da7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,29 +7,17 @@ on: env: REGISTRY: ghcr.io - IMAGE_BASE: ${{ github.repository }} jobs: - build-and-push: + # --- JOB 1: BACKEND (Dockerized for GHCR) --- + build-backend: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - service: backend - context: ./backend - image: ghcr.io/${{ github.repository }}/backend - - service: frontend - context: ./frontend - image: ghcr.io/${{ github.repository }}/frontend - permissions: contents: read packages: write - id-token: write # Required for OIDC and Build Provenance - security-events: write - attestations: write # NEW: Specifically required for official GitHub Attestations - + id-token: write + security-events: write + attestations: write steps: - name: Checkout repository uses: actions/checkout@v4 @@ -37,63 +25,87 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to the Container registry + - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ matrix.image }} - tags: | - type=semver,pattern={{version}} - type=sha,prefix=sha- - type=raw,value=latest,enable=${{ github.event_name == 'release' }} - - - name: Build and push Docker image + - name: Build and push Backend Image id: build-push - uses: docker/build-push-action@v6 # Updated to v6 + uses: docker/build-push-action@v6 with: - context: ${{ matrix.context }} + context: ./backend push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + # Tags both 'latest' and the specific release version (e.g., v1.0.0) + tags: | + ${{ env.REGISTRY }}/${{ github.repository }}/backend:latest + ${{ env.REGISTRY }}/${{ github.repository }}/backend:${{ github.event.release.tag_name || github.sha }} cache-from: type=gha cache-to: type=gha,mode=max - - name: Run Trivy vulnerability scanner - # Pinned to v0.35.0 specifically for security after March 2026 compromise - uses: aquasecurity/trivy-action@v0.35.0 - with: - image-ref: ${{ matrix.image }}@${{ steps.build-push.outputs.digest }} - format: 'sarif' - output: 'trivy-results.sarif' - severity: 'CRITICAL,HIGH' + - name: Sign Backend Image + run: | + cosign sign --yes "${{ env.REGISTRY }}/${{ github.repository }}/backend@${{ steps.build-push.outputs.digest }}" + + # --- JOB 2: FRONTEND (ZIP Archive for Release Assets) --- + build-frontend: + runs-on: ubuntu-latest + permissions: + contents: write # Required to upload the ZIP to the Release + id-token: write + attestations: write - - name: Upload Trivy scan results to GitHub Security - uses: github/codeql-action/upload-sarif@v3 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 with: - sarif_file: 'trivy-results.sarif' - category: ${{ matrix.service }} + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: frontend + + - name: Create Placeholder Config + # This ensures the Angular build doesn't fail due to missing environment files + run: | + mkdir -p src/assets + echo '{"production": true}' > src/assets/config.json + working-directory: frontend + + - name: Build Angular App + run: npm run build -- --configuration=production + working-directory: frontend + + - name: Package Assets + run: | + cd frontend/dist/creative-studio + zip -r ../../../frontend-assets.zip . + cd ../../../ + # Generate SHA-256 checksum + sha256sum frontend-assets.zip > frontend-assets.zip.sha256 - name: Install Cosign uses: sigstore/cosign-installer@v3.5.0 - - name: Sign image and Attest SBOM - env: - DIGEST: ${{ steps.build-push.outputs.digest }} + - name: Sign Frontend Blob + # This signs the ZIP file using GitHub's OIDC identity run: | - cosign sign --yes "${{ matrix.image }}@${{ env.DIGEST }}" - cosign attest --yes --type cyclonedx --predicate <(trivy image --format cyclonedx "${{ matrix.image }}@${{ env.DIGEST }}") "${{ matrix.image }}@${{ env.DIGEST }}" + cosign sign-blob --yes frontend-assets.zip \ + --bundle frontend-assets.zip.bundle - - name: Attest Build Provenance - # Official name for the GA version of build provenance - uses: actions/attest-build-provenance@v1 + - name: Upload Artifacts to Release + uses: softprops/action-gh-release@v2 with: - subject-name: ${{ matrix.image }} - subject-digest: ${{ steps.build-push.outputs.digest }} - push-to-registry: true + files: | + frontend-assets.zip + frontend-assets.zip.sha256 + frontend-assets.zip.bundle + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 00000000..f95862dc --- /dev/null +++ b/deploy.sh @@ -0,0 +1,618 @@ +#!/bin/bash +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e + +# --- Configuration --- +REQUIRED_TERRAFORM_VERSION="1.15.1" +UPSTREAM_REPO_URL="https://github.com/GoogleCloudPlatform/gcc-creative-studio" +TEMPLATE_ENV_DIR="environments/example" +DEFAULT_ENV_NAME="dev" +DEFAULT_BRANCH_NAME="main" +GCS_BUCKET_SUFFIX_FORMAT="cstudio-%s-tfstate" +GCS_BUCKET_PREFIX_FORMAT="terraform/state/%s" +BE_SERVICE_NAME="cstudio-backend" +FE_SERVICE_NAME="cstudio-frontend" + +# script will automatically set these +AUTO_FIREBASE_API_KEY="" # Your Firebase Web API Key +AUTO_FIREBASE_AUTH_DOMAIN="" # Your Firebase Auth Domain +AUTO_FIREBASE_PROJECT_ID="" # Your Firebase Project ID +AUTO_FIREBASE_STORAGE_BUCKET="" # Your Firebase Storage Bucket +AUTO_FIREBASE_MESSAGING_SENDER_ID="" # Your Firebase FCM Sender ID +AUTO_FIREBASE_APP_ID="" # Your Firebase Web App ID +AUTO_FIREBASE_MEASUREMENT_ID="" # Your Analytics ID +AUTO_OAUTH_CLIENT_ID="" +AUTO_FIREBASE_SITE_ID="" # Dynamic discovered Firebase site + +STATE_FILE="" +REPO_ROOT="" + +# --- Color Definitions (High Contrast) --- +C_RESET='\033[0m' +C_RED='\033[1;31m' # Bold/Bright Red for errors +C_GREEN='\033[1;32m' # Bold/Bright Green for success +C_YELLOW='\033[1;33m' # Bold/Bright Yellow for warnings +C_BLUE='\033[1;34m' # Bold/Bright Blue for steps and prompts +C_CYAN='\033[1;36m' # Bold/Bright Cyan for general info + +# --- Helper Functions --- +info() { echo -e "${C_CYAN}➡️ $1${C_RESET}"; } +prompt() { echo -e "${C_BLUE}🤔 $1${C_RESET}"; } +warn() { echo -e "${C_YELLOW}⚠️ $1${C_RESET}"; } +fail() { echo -e "${C_RED}❌ $1${C_RESET}" >&2; exit 1; } +success() { echo -e "${C_GREEN}✅ $1${C_RESET}"; } +step() { echo -e "\n${C_BLUE}--- Step $1: $2 ---${C_RESET}"; } + +# --- Terminal Spinner --- +spinner() { + local pid=$1 + local delay=0.1 + local spinstr='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' + tput civis # Hide cursor + while [ "$(ps a | awk '{print $1}' | grep $pid)" ]; do + local temp=${spinstr#?} + printf " ${C_CYAN}[%c]${C_RESET} " "$spinstr" + local spinstr=$temp${spinstr%"$temp"} + sleep $delay + printf "\b\b\b\b\b\b" + done + printf " \b\b\b\b\b\b" + tput cnorm # Show cursor +} + +# --- State Management --- +write_state() { + if [ -z "$STATE_FILE" ]; then return; fi + if ! ( + touch "$STATE_FILE" + TMP_STATE_FILE=$(mktemp) + grep -v "^$1=" "$STATE_FILE" > "$TMP_STATE_FILE" || true + echo "$1=$2" >> "$TMP_STATE_FILE" + mv "$TMP_STATE_FILE" "$STATE_FILE" + ); then + warn "Could not write to state file: $STATE_FILE. Resuming will not be possible." + fi +} +read_state() { + if [ -f "$STATE_FILE" ]; then + info "Found previous state file. Resuming..." + set -a; source "$STATE_FILE"; set +a + fi +} + +# --- Firebase Discovery --- +configure_firebase_site_id() { + info "Checking Firebase Hosting Site configuration..." + local tfvars_file=$1 + local project_id=$2 + + if grep -q "YOUR_FIREBASE_SITE_ID" "$tfvars_file" 2>/dev/null || grep -q "firebase_site_id[[:space:]]*=" "$tfvars_file" 2>/dev/null; then + local current_site_val=$(grep 'firebase_site_id' "$tfvars_file" | awk -F'"' '{print $2}' 2>/dev/null || echo "") + if [ -z "$current_site_val" ] || [ "$current_site_val" == "YOUR_FIREBASE_SITE_ID" ]; then + warn "Placeholder or empty 'firebase_site_id' found in ${tfvars_file}." + info "Querying Firebase for an existing default hosting site..." + + local default_site_name + default_site_name=$(firebase hosting:sites:list --project "$project_id" --json | jq -r 'first(.result.sites[] | select(.type == "DEFAULT_SITE") | .name) // first(.result.sites[].name) // ""' 2>/dev/null || echo "") + + local site_id_to_use=$project_id + [ -n "$default_site_name" ] && site_id_to_use=$(basename "$default_site_name") + + info "Setting 'firebase_site_id' to '${C_YELLOW}${site_id_to_use}${C_RESET}' in ${tfvars_file}." + + if grep -q "firebase_site_id" "$tfvars_file"; then + sed -i.bak "s|^[#[:space:]]*firebase_site_id[[:space:]]*=.*|firebase_site_id = \"${site_id_to_use}\"|g" "$tfvars_file" && rm -f "${tfvars_file}.bak" + else + echo -e "\nfirebase_site_id = \"${site_id_to_use}\"" >> "$tfvars_file" + fi + fi + else + # Append if missing + info "Querying default Firebase site..." + local default_site_name + default_site_name=$(firebase hosting:sites:list --project "$project_id" --json | jq -r 'first(.result.sites[] | select(.type == "DEFAULT_SITE") | .name) // first(.result.sites[].name) // ""' 2>/dev/null || echo "") + local site_id_to_use=$project_id + [ -n "$default_site_name" ] && site_id_to_use=$(basename "$default_site_name") + echo -e "\nfirebase_site_id = \"${site_id_to_use}\"" >> "$tfvars_file" + fi +} + +prompt_and_update_tfvar() { + local prompt_text=$1 + local default_value=$2 + local tfvar_name=$3 + local var_to_set_ref=$4 + + read -p " $prompt_text [default value: $default_value]: " user_input < /dev/tty + local final_value=${user_input:-$default_value} + + sed -i.bak "s|^[#[:space:]]*${tfvar_name}[[:space:]]*=.*|${tfvar_name} = \"${final_value}\"|g" "$TFVARS_FILE_PATH" && rm -f "${TFVARS_FILE_PATH}.bak" + eval "$var_to_set_ref='$final_value'" +} + +# --- Script Core Phases --- + +check_prerequisites() { + step 1 "Checking Prerequisites" + command -v gcloud >/dev/null || fail "gcloud CLI not found. Please install." + command -v git >/dev/null || fail "git not found. Please install it." + if ! command -v jq &> /dev/null; then + fail "The 'jq' command is required but not found. Please install it." + fi + if ! command -v firebase &> /dev/null; then + fail "Firebase CLI ('firebase-tools') is not installed. Please run 'npm install -g firebase-tools'." + fi + if ! command -v node &> /dev/null; then + fail "Node.js is not found. Please install it." + fi + if ! command -v npm &> /dev/null; then + fail "npm is not found. Please install it." + fi + success "Prerequisites met: gcloud, git, jq, firebase, node, npm." +} + +check_and_install_terraform() { + step 2 "Checking Terraform Installation" + if ! command -v terraform &> /dev/null; then + warn "Terraform is not installed." + install_terraform + return + fi + INSTALLED_VERSION=$(terraform version -json | jq -r .terraform_version) + if [[ "$(printf '%s\n' "$REQUIRED_TERRAFORM_VERSION" "$INSTALLED_VERSION" | sort -V | head -n1)" != "$REQUIRED_TERRAFORM_VERSION" ]]; then + warn "Your Terraform version ($INSTALLED_VERSION) is older than required ($REQUIRED_TERRAFORM_VERSION)." + install_terraform + else + success "Terraform version $INSTALLED_VERSION is sufficient." + fi +} + +install_terraform() { + warn "Terraform is missing or outdated. Installing version $REQUIRED_TERRAFORM_VERSION..." + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + case $ARCH in + x86_64) ARCH="amd64" ;; aarch64) ARCH="arm64" ;; arm64) ARCH="arm64" ;; + esac + PLATFORM_ARCH="${OS}_${ARCH}" + TF_ZIP_FILENAME="terraform_${REQUIRED_TERRAFORM_VERSION}_${PLATFORM_ARCH}.zip" + TF_DOWNLOAD_URL="https://releases.hashicorp.com/terraform/${REQUIRED_TERRAFORM_VERSION}/${TF_ZIP_FILENAME}" + + info "Downloading Terraform..." + curl -Lo terraform.zip "$TF_DOWNLOAD_URL" + unzip -o terraform.zip + mkdir -p "$HOME/bin" + mv terraform "$HOME/bin/" + if ! grep -q 'export PATH="$HOME/bin:$PATH"' ~/.bashrc; then + echo -e '\n# Add local bin to PATH\nexport PATH="$HOME/bin:$PATH"' >> ~/.bashrc + fi + export PATH="$HOME/bin:$PATH" + hash -r + rm -f terraform.zip LICENSE.txt + + if command -v terraform &> /dev/null && [[ "$(terraform version -json | jq -r .terraform_version)" == "$REQUIRED_TERRAFORM_VERSION" ]]; then + success "Terraform v$(terraform -version | head -n 1) is active." + else + fail "Terraform installation failed." + fi +} + +setup_project() { + step 3 "Configuring Google Cloud Project" + CURRENT_GCLOUD_PROJECT=$(gcloud config get-value project 2>/dev/null || echo "") + + if [ -n "$GCP_PROJECT_ID" ]; then + prompt "Found project '$GCP_PROJECT_ID' from previous run. Use this project? (y/n)" + read -r REPLY < /dev/tty + if [[ $REPLY =~ ^[Yy]$ ]]; then + gcloud config set project "$GCP_PROJECT_ID" + success "Project '$GCP_PROJECT_ID' configured." + return + fi + elif [ -n "$CURRENT_GCLOUD_PROJECT" ]; then + prompt "Detected active gcloud project '$CURRENT_GCLOUD_PROJECT'. Use this? (y/n)" + read -r REPLY < /dev/tty + if [[ $REPLY =~ ^[Yy]$ ]]; then + GCP_PROJECT_ID=$CURRENT_GCLOUD_PROJECT + gcloud config set project "$GCP_PROJECT_ID" + success "Project '$GCP_PROJECT_ID' configured." + return + fi + fi + + prompt "Do you already have a Google Cloud Project to use? (y/n)" + read -r REPLY < /dev/tty + if [[ $REPLY =~ ^[Yy]$ ]]; then + prompt "Please enter your Google Cloud Project ID:" + read -p " Project ID: " GCP_PROJECT_ID < /dev/tty + else + prompt "What is the desired new Google Cloud Project ID? (e.g., my-creative-studio)" + read -p " Project ID: " GCP_PROJECT_ID < /dev/tty + prompt "What is your Google Cloud Billing Account ID? (Find with 'gcloud beta billing accounts list')" + read -p " Billing Account ID: " BILLING_ACCOUNT_ID < /dev/tty + + info "Creating project '$GCP_PROJECT_ID'..." + gcloud projects create "$GCP_PROJECT_ID" || warn "Project may already exist. Continuing..." + info "Linking billing account..." + gcloud beta billing projects link "$GCP_PROJECT_ID" --billing-account="$BILLING_ACCOUNT_ID" + fi + gcloud config set project "$GCP_PROJECT_ID" + success "Project '$GCP_PROJECT_ID' is ready." +} + +setup_repo() { + step 4 "Configuring Git Repository Context" + if [[ -f "bootstrap.sh" && -d "infrastructure" && -d "frontend" && -d "backend" ]]; then + REPO_ROOT=$(pwd) + export REPO_ROOT + + GITHUB_REPO_OWNER=$(git remote get-url origin 2>/dev/null | sed -n 's/.*github.com\/\(.*\)\/.*/\1/p' || echo "") + if [ -z "$GITHUB_REPO_OWNER" ]; then + GITHUB_REPO_OWNER="GoogleCloudPlatform" + fi + GITHUB_REPO_NAME=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") + GITHUB_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "main") + + success "Verified repository directory: $GITHUB_REPO_NAME ($GITHUB_BRANCH)" + return + fi + fail "This script must be executed from the root directory of your cloned creative-studio repository." +} + +configure_environment() { + step 5 "Configuring Terraform Environment" + cd "$REPO_ROOT/infrastructure" + if [ -z "$ENV_NAME" ]; then + prompt "What would you like to call this deployment environment?" + read -p " Environment Name [default: $DEFAULT_ENV_NAME]: " ENV_NAME < /dev/tty + ENV_NAME=${ENV_NAME:-$DEFAULT_ENV_NAME} + else + info "Using active environment: $ENV_NAME" + fi + + ENV_DIR="environments/$ENV_NAME" + TFVARS_FILE_PATH="$REPO_ROOT/infrastructure/$ENV_DIR/terraform.tfvars" + STATE_FILE="$REPO_ROOT/infrastructure/$ENV_DIR/.deploy_state" + read_state + + if [ ! -d "$ENV_DIR" ]; then + info "Creating new environment folder from template: $TEMPLATE_ENV_DIR" + cp -r "$TEMPLATE_ENV_DIR" "$ENV_DIR" + + prompt "Do you have an existing GCS bucket for Terraform state? (y/n)" + read -r REPLY < /dev/tty + if [[ $REPLY =~ ^[Yy]$ ]]; then + prompt "Please enter GCS bucket name:" + read -p " Bucket Name: " BUCKET_NAME < /dev/tty + else + BUCKET_SUFFIX=$(printf "$GCS_BUCKET_SUFFIX_FORMAT" "$ENV_NAME") + BUCKET_NAME="${GCP_PROJECT_ID}-${BUCKET_SUFFIX}" + info "Creating GCS bucket '$BUCKET_NAME' for state..." + gsutil mb -p "$GCP_PROJECT_ID" "gs://${BUCKET_NAME}" || warn "Bucket may already exist. Continuing..." + fi + + BUCKET_PREFIX=$(printf "$GCS_BUCKET_PREFIX_FORMAT" "$ENV_NAME") + info "Updating backend.tfvars with: $BUCKET_PREFIX" + echo -e "bucket = \"$BUCKET_NAME\"\nprefix = \"$BUCKET_PREFIX\"" > "$ENV_DIR/backend.tfvars" + + # Populate tfvars file + info "Configuring terraform.tfvars..." + sed -i.bak "s|^[#[:space:]]*project_id[[:space:]]*=.*|project_id = \"$GCP_PROJECT_ID\"|g" "$TFVARS_FILE_PATH" + sed -i.bak "s|^[#[:space:]]*environment[[:space:]]*=.*|environment = \"$ENV_NAME\"|g" "$TFVARS_FILE_PATH" + + prompt_and_update_tfvar "Region to deploy resources into" "us-central1" "region" "DEPLOY_REGION" + prompt_and_update_tfvar "Resource naming prefix" "cs" "resource_prefix" "RES_PREFIX" + prompt_and_update_tfvar "Custom domain name (Load Balancer SSL)" "${GCP_PROJECT_ID}.example.com" "domain_name" "LB_DOMAIN" + + # Discover and Set Firebase Site ID + configure_firebase_site_id "$TFVARS_FILE_PATH" "$GCP_PROJECT_ID" + AUTO_FIREBASE_SITE_ID=$(grep 'firebase_site_id' "$TFVARS_FILE_PATH" | awk -F'"' '{print $2}') + + write_state "ENV_NAME" "$ENV_NAME" + write_state "DEPLOY_REGION" "$DEPLOY_REGION" + write_state "RES_PREFIX" "$RES_PREFIX" + write_state "LB_DOMAIN" "$LB_DOMAIN" + write_state "AUTO_FIREBASE_SITE_ID" "$AUTO_FIREBASE_SITE_ID" + else + info "Environment '$ENV_NAME' directory already exists." + fi + success "Configuration files for environment '$ENV_NAME' are ready." +} + +handle_manual_steps() { + step 6 "Enabling Google APIs & Accepting Terms" + cd "$REPO_ROOT/infrastructure" + info "Enabling required GCP Service APIs..." + gcloud services enable \ + cloudbuild.googleapis.com \ + secretmanager.googleapis.com \ + firebase.googleapis.com \ + iap.googleapis.com \ + identitytoolkit.googleapis.com \ + texttospeech.googleapis.com \ + workflows.googleapis.com \ + sqladmin.googleapis.com \ + compute.googleapis.com \ + vpcaccess.googleapis.com \ + --project="$GCP_PROJECT_ID" + + warn "\nTerraform cannot accept Google legal terms on your behalf." + info "Please guarantee Firebase integration manually:" + echo "1. Open URL in browser: ${C_YELLOW}https://console.firebase.google.com/?project=${GCP_PROJECT_ID}${C_RESET}" + echo "2. Confirm linking/adding Firebase to your project." + echo "3. Accept terms." + prompt "Press [Enter] after Firebase has been successfully linked..." + read -r < /dev/tty +} + +setup_firebase_app() { + step 7 "Configuring Firebase Web Application" + cd "$REPO_ROOT" + info "Checking for existing Firebase Web App '$FE_SERVICE_NAME'..." + if ! firebase apps:list --project="$GCP_PROJECT_ID" | grep -q "$FE_SERVICE_NAME"; then + info "Creating web app inside Firebase..." + firebase apps:create WEB "$FE_SERVICE_NAME" --project="$GCP_PROJECT_ID" + else + info "Firebase Web App already registered." + fi + + info "Querying Web App SDK config metadata..." + local APP_ID=$(firebase apps:list --project="$GCP_PROJECT_ID" --json | jq -r --arg name "$FE_SERVICE_NAME" '.result[] | select(.displayName == $name) | .appId') + local SDK_CONFIG_JSON=$(firebase apps:sdkconfig WEB "$APP_ID" --project="$GCP_PROJECT_ID" --json) + + AUTO_FIREBASE_API_KEY=$(echo "$SDK_CONFIG_JSON" | jq -r '.result.sdkConfig.apiKey // empty') + AUTO_FIREBASE_AUTH_DOMAIN=$(echo "$SDK_CONFIG_JSON" | jq -r '.result.sdkConfig.authDomain // empty') + AUTO_FIREBASE_PROJECT_ID=$(echo "$SDK_CONFIG_JSON" | jq -r '.result.sdkConfig.projectId // empty') + AUTO_FIREBASE_STORAGE_BUCKET=$(echo "$SDK_CONFIG_JSON" | jq -r '.result.sdkConfig.storageBucket // empty') + AUTO_FIREBASE_MESSAGING_SENDER_ID=$(echo "$SDK_CONFIG_JSON" | jq -r '.result.sdkConfig.messagingSenderId // empty') + AUTO_FIREBASE_APP_ID=$(echo "$SDK_CONFIG_JSON" | jq -r '.result.sdkConfig.appId // empty') + AUTO_FIREBASE_MEASUREMENT_ID=$(echo "$SDK_CONFIG_JSON" | jq -r '.result.sdkConfig.measurementId // empty') + + if [ -z "$AUTO_FIREBASE_API_KEY" ]; then + fail "Failed to query Firebase credentials automatically. Verify Firebase project Console settings." + fi + success "Firebase configuration details successfully discovered." +} + +populate_oauth_secrets() { + step 8 "Configuring OAuth Web Client ID" + cd "$REPO_ROOT" + info "Resolving Google Client ID for secure frontend/backend token transactions..." + + local APP_ID=$(firebase apps:list --project="$GCP_PROJECT_ID" --json | jq -r --arg name "$FE_SERVICE_NAME" '.result[] | select(.displayName == $name) | .appId') + local AUTH_TOKEN=$(gcloud auth print-access-token) + local API_RESPONSE=$(curl -s -X GET -H "Authorization: Bearer $AUTH_TOKEN" "https://firebase.googleapis.com/v1beta1/projects/$GCP_PROJECT_ID/webApps/$APP_ID/config") + AUTO_OAUTH_CLIENT_ID=$(echo "$API_RESPONSE" | jq -r '.oauthClientId // empty') + + if [ -z "$AUTO_OAUTH_CLIENT_ID" ] || [ "$AUTO_OAUTH_CLIENT_ID" == "null" ]; then + warn "Could not resolve OAuth client ID via APIs automatically." + echo "1. Open URL in browser: ${C_YELLOW}https://console.cloud.google.com/apis/credentials?project=${GCP_PROJECT_ID}${C_RESET}" + echo "2. Locate the Web Application client under 'OAuth 2.0 Client IDs'." + prompt "Paste the OAuth 2.0 Client ID here:" + read -p " Client ID: " AUTO_OAUTH_CLIENT_ID < /dev/tty + if [ -z "$AUTO_OAUTH_CLIENT_ID" ]; then fail "OAuth Client ID is required."; fi + fi + + info "Writing client ID secrets directly to Google Secret Manager..." + # Ensure standard secret names exist before adding versions (handled by Terraform, but we seed placeholders) + if ! gcloud secrets describe GOOGLE_CLIENT_ID --project="$GCP_PROJECT_ID" >/dev/null 2>&1; then + gcloud secrets create GOOGLE_CLIENT_ID --replication-policy="automatic" --project="$GCP_PROJECT_ID" + fi + if ! gcloud secrets describe GOOGLE_TOKEN_AUDIENCE --project="$GCP_PROJECT_ID" >/dev/null 2>&1; then + gcloud secrets create GOOGLE_TOKEN_AUDIENCE --replication-policy="automatic" --project="$GCP_PROJECT_ID" + fi + + echo -n "$AUTO_OAUTH_CLIENT_ID" | gcloud secrets versions add GOOGLE_CLIENT_ID --data-file="-" --project="$GCP_PROJECT_ID" --quiet + echo -n "$AUTO_OAUTH_CLIENT_ID" | gcloud secrets versions add GOOGLE_TOKEN_AUDIENCE --data-file="-" --project="$GCP_PROJECT_ID" --quiet + success "Secure OAuth environment bindings successfully populated." +} + +run_terraform() { + step 9 "Provisioning Google Cloud Infrastructure via Terraform" + cd "$REPO_ROOT/infrastructure" + ENV_DIR="environments/$ENV_NAME" + + info "Initializing Terraform with GCS Backend State config: $ENV_DIR/backend.tfvars..." + terraform init -reconfigure -backend-config="$ENV_DIR/backend.tfvars" + + info "Running Terraform Plan..." + terraform plan -var-file="$ENV_DIR/terraform.tfvars" + + prompt "\nReady to apply state modifications. Provision resources? (y/n)" + read -r REPLY < /dev/tty + if [[ ! $REPLY =~ ^[Yy]$ ]]; then fail "Deployment halted by user."; fi + + info "Applying infrastructure deployment configuration..." + terraform apply -auto-approve -var-file="$ENV_DIR/terraform.tfvars" + success "Infrastructure provisioned successfully." +} + +build_and_deploy_frontend() { + step 10 "Compiling & Deploying Frontend App (Dynamic Config)" + cd "$REPO_ROOT/frontend" + + info "Restoring backup of firebase.json if present..." + [ -f "firebase.json.bak" ] && cp "firebase.json.bak" "firebase.json" + + # Resolve dynamic values from Terraform outputs + info "Extracting deployed routing links from infrastructure..." + cd "$REPO_ROOT/infrastructure" + local BACKEND_URL=$(terraform output -raw backend_service_url 2>/dev/null || echo "") + local BACKEND_SERVICE_NAME=$(terraform output -raw service_name 2>/dev/null || echo "") + + if [ -z "$BACKEND_URL" ] || [ -z "$BACKEND_SERVICE_NAME" ]; then + fail "Failed to query backend outputs from Terraform. Ensure Terraform apply was complete and outputs exist." + fi + + info "Discovered backend URL: $BACKEND_URL" + info "Discovered backend name: $BACKEND_SERVICE_NAME" + + cd "$REPO_ROOT/frontend" + info "Replacing template parameters inside firebase.json config..." + cp "firebase.json" "firebase.json.bak" + sed -i "s|SITE_ID_PLACEHOLDER|${AUTO_FIREBASE_SITE_ID}|g" firebase.json + sed -i "s|BACKEND_SERVICE_ID_PLACEHOLDER|${BACKEND_SERVICE_NAME}|g" firebase.json + + # 1. Standard package installation + info "Installing frontend npm packages..." + npm ci + + # 2. Compile static web bundle + info "Executing generic Angular production build..." + npm run build -- --configuration=production + + # 3. Create the client runtime config.json file dynamically inside browser assets + info "Generating dynamic assets/config.json runtime config..." + local CONFIG_PATH="dist/creative-studio/browser/assets/config.json" + mkdir -p "dist/creative-studio/browser/assets" + + jq -n \ + --argjson production true \ + --argjson isLocal false \ + --arg backendURL "${BACKEND_URL}" \ + --arg googleClientId "$AUTO_OAUTH_CLIENT_ID" \ + --arg apiKey "$AUTO_FIREBASE_API_KEY" \ + --arg authDomain "$AUTO_FIREBASE_AUTH_DOMAIN" \ + --arg projectId "$AUTO_FIREBASE_PROJECT_ID" \ + --arg storageBucket "$AUTO_FIREBASE_STORAGE_BUCKET" \ + --arg messagingSenderId "$AUTO_FIREBASE_MESSAGING_SENDER_ID" \ + --arg appId "$AUTO_FIREBASE_APP_ID" \ + --arg measurementId "$AUTO_FIREBASE_MEASUREMENT_ID" \ + '{ + production: $production, + isLocal: $isLocal, + backendURL: ($backendURL + "/api"), + GOOGLE_CLIENT_ID: $googleClientId, + firebase: { + apiKey: $apiKey, + authDomain: $authDomain, + projectId: $projectId, + storageBucket: $storageBucket, + messagingSenderId: $messagingSenderId, + appId: $appId, + measurementId: $measurementId + } + }' > "$CONFIG_PATH" + + # 4. Deploy SPA statically to Firebase hosting site + info "Deploying static assets to Firebase Hosting site: ${AUTO_FIREBASE_SITE_ID}..." + npx firebase deploy --project="$GCP_PROJECT_ID" --only="hosting:${AUTO_FIREBASE_SITE_ID}" --non-interactive + + # Restore original firebase.json + mv "firebase.json.bak" "firebase.json" + success "Frontend built, dynamic client configuration created, and deployed." +} + +seed_database() { + step 11 "Executing Database Migrations & Initial Seeding" + cd "$REPO_ROOT/infrastructure" + + # 1. Fetch secure outputs from Terraform + info "Resolving secure database credentials..." + local DB_CONN_NAME=$(terraform output -raw cloud_sql_connection_name 2>/dev/null || echo "") + local DB_NAME=$(terraform output -raw db_name 2>/dev/null || echo "") + local DB_USER=$(terraform output -raw db_user 2>/dev/null || echo "") + local DB_PASS_SECRET=$(terraform output -raw db_secret_id 2>/dev/null || echo "") + local SUBNET_NAME=$(terraform output -raw cloud_run_subnet_name 2>/dev/null || echo "") + + if [ -z "$DB_CONN_NAME" ] || [ -z "$DB_PASS_SECRET" ] || [ -z "$SUBNET_NAME" ]; then + fail "Could not query network or database outputs. Verify Terraform apply ran successfully." + fi + + # 2. Deduce dynamic billing proxy registry image URL + local STABLE_IMAGE="${DEPLOY_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${RES_PREFIX}-${ENV_NAME}-ghcr-proxy/GoogleCloudPlatform/gcc-creative-studio/backend:latest" + info "Target secure runtime image: ${C_YELLOW}${STABLE_IMAGE}${C_RESET}" + + info "Database: ${DB_CONN_NAME}" + info "Subnetwork Egress: ${SUBNET_NAME}" + + local CURRENT_USER=$(gcloud config get-value account 2>/dev/null || echo "system") + local BUCKET_ASSETS="${GCP_PROJECT_ID}-cs-${ENV_NAME}-bucket" + + # 3. Create a secure, temporary Google Cloud Run Job inside the VPC boundary + info "Registering secure administrative Job..." + + # Delete temporary job if it exists from previous crashed runs + gcloud run jobs delete temp-db-bootstrap-job --region="$DEPLOY_REGION" --project="$GCP_PROJECT_ID" --quiet >/dev/null 2>&1 || true + + gcloud run jobs create temp-db-bootstrap-job \ + --image="$STABLE_IMAGE" \ + --region="$DEPLOY_REGION" \ + --subnet="$SUBNET_NAME" \ + --command="python" \ + --args="-m,bootstrap.bootstrap" \ + --add-cloudsql-instances="$DB_CONN_NAME" \ + --set-env-vars="INSTANCE_CONNECTION_NAME=${DB_CONN_NAME},DB_HOST=/cloudsql/${DB_CONN_NAME},DB_NAME=${DB_NAME},DB_USER=${DB_USER},USE_CLOUD_SQL_AUTH_PROXY=true,PROJECT_ID=${GCP_PROJECT_ID},GENMEDIA_BUCKET=${BUCKET_ASSETS},ADMIN_USER_EMAIL=${CURRENT_USER},ENVIRONMENT=development" \ + --set-secrets="DB_PASS=${DB_PASS_SECRET}:latest" \ + --project="$GCP_PROJECT_ID" \ + --quiet + + # 4. Trigger Job execution serverless and wait for completion + info "Triggering migration and seeding execution in Cloud Run Job..." + if gcloud run jobs execute temp-db-bootstrap-job --region="$DEPLOY_REGION" --project="$GCP_PROJECT_ID" --wait --quiet; then + success "Database migrations and initial database data seeding executed successfully!" + else + warn "Database seeding failed. Retrying in background or check logs inside Cloud Run Job console." + gcloud run jobs delete temp-db-bootstrap-job --region="$DEPLOY_REGION" --project="$GCP_PROJECT_ID" --quiet >/dev/null 2>&1 || true + fail "Database initialization aborted due to seeding job error." + fi + + # 5. Clean up administrative Job + info "Cleaning up temporary seeding job..." + gcloud run jobs delete temp-db-bootstrap-job --region="$DEPLOY_REGION" --project="$GCP_PROJECT_ID" --quiet + success "Temporary serverless seeding infrastructure securely dismantled." +} + +# --- Main Execution --- +main() { + echo -e "${C_GREEN}============================================================${C_RESET}" + echo -e "${C_GREEN} 🚀 Creative Studio Enterprise Deployer (Secure SPA) 🚀 ${C_RESET}" + echo -e "${C_GREEN}============================================================${C_RESET}" + + check_prerequisites + check_and_install_terraform + setup_project + setup_repo + configure_environment + handle_manual_steps + setup_firebase_app + populate_oauth_secrets + run_terraform + build_and_deploy_frontend + seed_database + + step 12 "🎉 Deployment Completed Successfully! 🎉" + + cd "$REPO_ROOT/infrastructure" + local FRONTEND_URL=$(terraform output -raw frontend_service_url 2>/dev/null || echo "") + if [ -z "$FRONTEND_URL" ] || [ "$FRONTEND_URL" == "null" ]; then + if [ -n "$AUTO_FIREBASE_SITE_ID" ]; then + FRONTEND_URL="https://${AUTO_FIREBASE_SITE_ID}.web.app" + else + FRONTEND_URL="https://${GCP_PROJECT_ID}.web.app" + fi + fi + local BACKEND_URL=$(terraform output -raw backend_service_url 2>/dev/null || echo "") + + echo "------------------------------------------------------------------" + echo -e " Frontend Portal URL: ${C_YELLOW}${FRONTEND_URL}${C_RESET}" + echo -e " Backend API Endpoint: ${C_YELLOW}${BACKEND_URL}${C_RESET}" + echo "------------------------------------------------------------------" + info "The application services are secure, live, and fully operational." + echo -e "${C_GREEN}============================================================${C_RESET}" +} + +main "$@" diff --git a/frontend/.gitignore b/frontend/.gitignore index bbe6713a..29191d1d 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -5,6 +5,8 @@ !environment.ts !environment.prod.ts proxy.conf.json +public/assets/config.json + # Compiled output /dist diff --git a/frontend/public/assets/config.json.example b/frontend/public/assets/config.json.example new file mode 100644 index 00000000..872b7d5c --- /dev/null +++ b/frontend/public/assets/config.json.example @@ -0,0 +1,15 @@ +{ + "production": true, + "isLocal": false, + "backendURL": "https://cstudio-backend-dev.mydomain.com/api", + "GOOGLE_CLIENT_ID": "your-oauth-web-client-id-here.apps.googleusercontent.com", + "firebase": { + "apiKey": "your-firebase-api-key", + "authDomain": "your-firebase-auth-domain.firebaseapp.com", + "projectId": "your-firebase-project-id", + "storageBucket": "your-firebase-storage-bucket.appspot.com", + "messagingSenderId": "your-firebase-messaging-sender-id", + "appId": "your-firebase-web-app-id", + "measurementId": "your-google-analytics-measurement-id" + } +} diff --git a/infrastructure/.terraform-version b/infrastructure/.terraform-version new file mode 100644 index 00000000..ace44233 --- /dev/null +++ b/infrastructure/.terraform-version @@ -0,0 +1 @@ +1.15.1 diff --git a/infrastructure/.terraform.lock.hcl b/infrastructure/.terraform.lock.hcl new file mode 100644 index 00000000..2b9de388 --- /dev/null +++ b/infrastructure/.terraform.lock.hcl @@ -0,0 +1,63 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/google" { + version = "6.50.0" + constraints = ">= 6.0.0, ~> 6.0, < 7.0.0" + hashes = [ + "h1:faTJQOetP9/RYuHwA3r2SWnuYoyzQNm4tUWZrZggcgY=", + "zh:1f3513fcfcbf7ca53d667a168c5067a4dd91a4d4cccd19743e248ff31065503c", + "zh:3da7db8fc2c51a77dd958ea8baaa05c29cd7f829bd8941c26e2ea9cb3aadc1e5", + "zh:3e09ac3f6ca8111cbb659d38c251771829f4347ab159a12db195e211c76068bb", + "zh:7bb9e41c568df15ccf1a8946037355eefb4dfb4e35e3b190808bb7c4abae547d", + "zh:81e5d78bdec7778e6d67b5c3544777505db40a826b6eb5abe9b86d4ba396866b", + "zh:8d309d020fb321525883f5c4ea864df3d5942b6087f6656d6d8b3a1377f340fc", + "zh:93e112559655ab95a523193158f4a4ac0f2bfed7eeaa712010b85ebb551d5071", + "zh:d3efe589ffd625b300cef5917c4629513f77e3a7b111c9df65075f76a46a63c7", + "zh:d4a4d672bbef756a870d8f32b35925f8ce2ef4f6bbd5b71a3cb764f1b6c85421", + "zh:e13a86bca299ba8a118e80d5f84fbdd708fe600ecdceea1a13d4919c068379fe", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:fec30c095647b583a246c39d557704947195a1b7d41f81e369ba377d997faef6", + ] +} + +provider "registry.terraform.io/hashicorp/google-beta" { + version = "6.50.0" + constraints = ">= 6.0.0, ~> 6.0, < 7.0.0" + hashes = [ + "h1:uxh4ME3hhSzVjmiWgA1IQqYqg25MV6FMVArHyA6Ki5o=", + "zh:18b442bd0a05321d39dda1e9e3f1bdede4e61bc2ac62cc7a67037a3864f75101", + "zh:2e387c51455862828bec923a3ec81abf63a4d998da470cf00e09003bda53d668", + "zh:3942e708fa84ebe54996086f4b1398cb747fe19cbcd0be07ace528291fb35dee", + "zh:496287dd48b34ae6197cb1f887abeafd07c33f389dbe431bb01e24846754cfdd", + "zh:6eca885419969ce5c2a706f34dce1f10bde9774757675f2d8a92d12e5a1be390", + "zh:710dbef826c3fe7f76f844dae47937e8e4c1279dd9205ec4610be04cf3327244", + "zh:777ebf44b24bfc7bdbf770dc089f1a72f143b4718fdedb8c6bd75983115a1ec2", + "zh:9c8703bba37b8c7ad857efc3513392c5a096c519397c1cb822d7612f38e4262f", + "zh:c4f1d3a73de2702277c99d5348ad6d374705bcfdd367ad964ff4cfd2cf06c281", + "zh:eca8df11af3f5a948492d5b8b5d01b4ec705aad10bc30ec1524205508ae28393", + "zh:f41e7fd5f2628e8fd6b8ea136366923858f54428d1729898925469b862c275c2", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = ">= 2.1.0" + hashes = [ + "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} diff --git a/infrastructure/app.tf b/infrastructure/app.tf new file mode 100644 index 00000000..0f3437fb --- /dev/null +++ b/infrastructure/app.tf @@ -0,0 +1,27 @@ +# --- Artifact Registry Module --- +module "artifact" { + source = "./modules/artifact" + + project_id = var.project_id + region = var.region + resource_prefix = var.resource_prefix + environment = var.environment +} + +# --- Compute Module (Cloud Run) --- +module "compute" { + source = "./modules/compute" + + project_id = var.project_id + region = var.region + resource_prefix = var.resource_prefix + environment = var.environment + + # Direct VPC routing configurations + vpc_subnet_name = module.network.cloud_run_subnet_name + database_ip = module.database.private_ip_address + image_url = "${module.artifact.repository_url}/${var.backend_image_name}:${var.backend_image_tag}" + + # References the list keys to configure secret environment block mappings + secret_ids = var.application_secrets +} diff --git a/infrastructure/data.tf b/infrastructure/data.tf new file mode 100644 index 00000000..9518c9e9 --- /dev/null +++ b/infrastructure/data.tf @@ -0,0 +1,80 @@ +# --- Database Module --- +module "database" { + source = "./modules/database" + + project_id = var.project_id + region = var.region + resource_prefix = var.resource_prefix + environment = var.environment + + vpc_id = module.network.network_id + + # Guardrail: Explicitly wait for Private Services Access peering to + # finish provisioning before kicking off database creation. + depends_on = [module.network.peering_completed] +} + +# --- Storage Buckets --- +resource "google_storage_bucket" "genmedia" { + name = "${var.project_id}-cs-${var.environment}-bucket" + location = var.region + uniform_bucket_level_access = true + + cors { + origin = ["*"] + method = ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"] + response_header = ["Content-Type", "Access-Control-Allow-Origin", "x-goog-resumable", "Authorization", "Origin"] + max_age_seconds = 3600 + } +} + +resource "google_service_account" "bucket_reader_sa" { + account_id = "cs-${var.environment}-bkt-reader" + display_name = "SA for reading GenMedia (${var.environment}) bucket" +} + +resource "google_storage_bucket_iam_member" "bucket_viewer_binding" { + bucket = google_storage_bucket.genmedia.name + role = "roles/storage.objectViewer" + member = "serviceAccount:${google_service_account.bucket_reader_sa.email}" +} + +resource "google_storage_bucket_iam_member" "object_creator_binding" { + bucket = google_storage_bucket.genmedia.name + role = "roles/storage.objectCreator" + member = "serviceAccount:${google_service_account.bucket_reader_sa.email}" +} + +# --- Secret Manager --- +# Create the "shell" for each secret in the specified application list +resource "google_secret_manager_secret" "app_secrets" { + for_each = var.application_secrets + + project = var.project_id + secret_id = each.key + + replication { + user_managed { + replicas { + location = var.region # restricted to the infra region + } + } + } + + labels = merge(var.labels, { + component = "security" + managed_by = "terraform" + }) +} + +# Grant Secret Access directly to the Cloud Run Service Account +resource "google_secret_manager_secret_iam_member" "backend_accessor" { + for_each = var.application_secrets + + project = google_secret_manager_secret.app_secrets[each.key].project + secret_id = google_secret_manager_secret.app_secrets[each.key].secret_id + role = "roles/secretmanager.secretAccessor" + + # References backend module's service account output dynamically + member = "serviceAccount:${module.compute.service_account_email}" +} diff --git a/infrastructure/hosting.tf b/infrastructure/hosting.tf new file mode 100644 index 00000000..8897815a --- /dev/null +++ b/infrastructure/hosting.tf @@ -0,0 +1,21 @@ + +# Creates the Firebase Hosting site to deploy to +resource "google_firebase_hosting_site" "frontend" { + provider = google-beta + project = var.project_id + site_id = var.firebase_site_id +} + +# Conditionally map the Custom Domain if provided +resource "google_firebase_hosting_custom_domain" "custom_domain" { + provider = google-beta + # Evaluates to 1 if a domain string is provided, 0 if left blank + count = var.custom_domain != "" ? 1 : 0 + + project = var.project_id + site_id = google_firebase_hosting_site.frontend.site_id + custom_domain = var.custom_domain + + # Prevent Terraform runs from timing out while waiting for manual DNS propagation + wait_dns_verification = false +} \ No newline at end of file diff --git a/infrastructure/locals.tf b/infrastructure/locals.tf new file mode 100644 index 00000000..c2d738ca --- /dev/null +++ b/infrastructure/locals.tf @@ -0,0 +1,3 @@ +data "google_project" "project" { + project_id = var.project_id +} \ No newline at end of file diff --git a/infrastructure/main.tf b/infrastructure/main.tf new file mode 100644 index 00000000..2f3f5912 --- /dev/null +++ b/infrastructure/main.tf @@ -0,0 +1,46 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = "~> 7.32.0" + } + google-beta = { + source = "hashicorp/google-beta" + version = "~> 7.32.0" + } + } + required_version = "~> 1.15.1" + + # File-per-environment state pattern: configuration details are passed + # via the CLI backend-config flag during execution. + backend "gcs" { + # Left empty intentionally. + # Filled dynamically via -backend-config during 'init' + } +} + +provider "google" { + project = var.project_id + region = var.region +} + +provider "google-beta" { + project = var.project_id + region = var.region +} + +# check platform.tf if you're looking for the crux of this module \ No newline at end of file diff --git a/infrastructure/modules/artifact/README.md b/infrastructure/modules/artifact/README.md new file mode 100644 index 00000000..d67815b2 --- /dev/null +++ b/infrastructure/modules/artifact/README.md @@ -0,0 +1,43 @@ +# Artifact Registry Module + +This module creates a Google Artifact Registry repository configured as a remote proxy for an external registry (defaulting to GitHub Container Registry). + +## Features + +- Remote Repository mode to proxy external registries. +- Vulnerability scanning enabled (inherited from project). +- Cleanup policy to delete stale cached layers. + +## Usage + +```hcl +module "artifact" { + source = "./modules/artifact" + + project_id = "my-project-id" + region = "us-central1" + resource_prefix = "cstudio" + environment = "dev" +} +``` + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| `project_id` | The GCP project ID. | `string` | n/a | yes | +| `region` | The GCP region where the Artifact Registry will be created. | `string` | n/a | yes | +| `resource_prefix` | Standard naming prefix assigned to the deployment. | `string` | n/a | yes | +| `environment` | Deployment environment identifier. | `string` | n/a | yes | +| `labels` | A map of labels to apply to the resource. | `map(string)` | `{}` | no | +| `repository_id` | The ID of the repository. | `string` | `"ghcr-proxy"` | no | +| `remote_uri` | The URI of the remote repository to proxy. | `string` | `"https://ghcr.io"` | no | +| `cleanup_older_than` | Cleanup policy condition: delete cached layers unaccessed for this duration. | `string` | `"2592000s"` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| `repository_id` | The ID of the Artifact Registry repository. | +| `repository_name` | The fully qualified name of the repository. | +| `repository_url` | The URL of the repository (without image name). | diff --git a/infrastructure/modules/artifact/main.tf b/infrastructure/modules/artifact/main.tf new file mode 100644 index 00000000..66b322c9 --- /dev/null +++ b/infrastructure/modules/artifact/main.tf @@ -0,0 +1,41 @@ +# infra/modules/artifact-registry/main.tf + +resource "google_artifact_registry_repository" "ghcr_proxy" { + location = var.region + repository_id = "${var.resource_prefix}-${var.environment}-${var.repository_id}" + description = "Regional proxy for GHCR with vulnerability scanning" + format = "DOCKER" + + # Remote Repository Mode + mode = "REMOTE_REPOSITORY" + + remote_repository_config { + description = "Proxy for GitHub Container Registry" + docker_repository { + custom_repository { + uri = var.remote_uri + } + } + } + + # Explicitly enable vulnerability scanning + # Note: This requires 'containerscanning.googleapis.com' to be enabled in the project + # Ensure the mirror scans every image it caches + vulnerability_scanning_config { + enablement_config = "INHERITED" # Inherits project-level scan settings + } + + # Cost Guardrail: Automatically purge cached layers unaccessed for 30 days + cleanup_policies { + id = "delete-stale-cache" + action = "DELETE" + condition { + older_than = var.cleanup_older_than + } + } + + labels = merge(var.labels, { + component = "security-mirror" + region = var.region + }) +} diff --git a/infrastructure/modules/artifact/outputs.tf b/infrastructure/modules/artifact/outputs.tf new file mode 100644 index 00000000..add7a4d6 --- /dev/null +++ b/infrastructure/modules/artifact/outputs.tf @@ -0,0 +1,14 @@ +output "repository_id" { + description = "The ID of the Artifact Registry repository." + value = google_artifact_registry_repository.ghcr_proxy.repository_id +} + +output "repository_name" { + description = "The fully qualified name of the repository." + value = google_artifact_registry_repository.ghcr_proxy.name +} + +output "repository_url" { + description = "The URL of the repository (without image name)." + value = "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.ghcr_proxy.repository_id}" +} diff --git a/infrastructure/modules/artifact/variables.tf b/infrastructure/modules/artifact/variables.tf new file mode 100644 index 00000000..95e74bee --- /dev/null +++ b/infrastructure/modules/artifact/variables.tf @@ -0,0 +1,43 @@ +variable "project_id" { + type = string + description = "The GCP project ID." +} + +variable "region" { + type = string + description = "The GCP region where the Artifact Registry will be created." +} + +variable "resource_prefix" { + type = string + description = "Standard naming prefix assigned to the deployment." +} + +variable "environment" { + type = string + description = "Deployment environment identifier." +} + +variable "labels" { + type = map(string) + description = "A map of labels to apply to the resource." + default = {} +} + +variable "repository_id" { + type = string + description = "The ID of the repository." + default = "ghcr-proxy" +} + +variable "remote_uri" { + type = string + description = "The URI of the remote repository to proxy." + default = "https://ghcr.io" +} + +variable "cleanup_older_than" { + type = string + description = "Cleanup policy condition: delete cached layers unaccessed for this duration." + default = "2592000s" +} diff --git a/infrastructure/modules/compute/README.md b/infrastructure/modules/compute/README.md new file mode 100644 index 00000000..325362eb --- /dev/null +++ b/infrastructure/modules/compute/README.md @@ -0,0 +1,74 @@ +# Compute Module (Cloud Run) + +This module creates a Cloud Run service for the backend application, along with a service account and necessary IAM bindings. It also creates a Serverless Network Endpoint Group (NEG) for Load Balancer integration. + +## Features + +- Deploys a Cloud Run service with specified image and resources. +- Configures environment variables and secrets. +- Attaches Cloud SQL instance via Cloud SQL Auth Proxy (implicit in Cloud Run). +- Sets up probes (startup and liveness). +- Creates a Serverless NEG. + +## Usage + +```hcl +module "compute" { + source = "./modules/compute" + + project_id = "my-project-id" + region = "us-central1" + environment = "dev" + resource_prefix = "cstudio" + service_name = "backend" + + # Image configuration + ar_repo_id = "ghcr-proxy" + github_org_or_user = "my-org" + github_repo_name = "my-repo" + image_tag = "latest" + + cloud_sql_connection_name = module.database.instance_connection_name + db_name = module.database.database_name + db_user = module.database.user_name + db_secret_id = module.database.secret_id +} +``` + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| `project_id` | The GCP project ID. | `string` | n/a | yes | +| `region` | The GCP region where resources will be created. | `string` | n/a | yes | +| `environment` | The deployment environment. | `string` | n/a | yes | +| `resource_prefix` | Prefix to be used for resource naming. | `string` | n/a | yes | +| `service_name` | The name of the Cloud Run service. | `string` | n/a | yes | +| `image_url` | The URL of the container image to deploy (fallback if not constructing). | `string` | n/a | yes | +| `ar_repo_id` | The Artifact Registry repository ID. | `string` | n/a | yes | +| `github_org_or_user` | The GitHub organization or user name. | `string` | n/a | yes | +| `github_repo_name` | The GitHub repository name. | `string` | n/a | yes | +| `image_tag` | The image tag to deploy. | `string` | n/a | yes | +| `custom_audiences` | Custom audiences for the Cloud Run service. | `list(string)` | `[]` | no | +| `cloud_sql_connection_name` | The connection name of the Cloud SQL instance. | `string` | n/a | yes | +| `db_name` | The name of the database to connect to. | `string` | n/a | yes | +| `db_user` | The database user for connection. | `string` | n/a | yes | +| `db_secret_id` | The Secret Manager secret ID containing the database password. | `string` | n/a | yes | +| `cpu` | CPU limit for the Cloud Run container. | `string` | `"1000m"` | no | +| `memory` | Memory limit for the Cloud Run container. | `string` | `"512Mi"` | no | +| `container_env_vars` | Map of non-secret environment variables. | `map(string)` | `{}` | no | +| `runtime_secrets` | Map of environment variable names to Secret Manager secret names. | `map(string)` | `{}` | no | +| `scaling_min_instances` | Minimum number of container instances. | `number` | `0` | no | +| `scaling_max_instances` | Maximum number of container instances. | `number` | `10` | no | +| `run_sa_project_roles` | List of IAM roles to assign to the Cloud Run service account. | `list(string)` | (see variables.tf) | no | + +## Outputs + +| Name | Description | +|------|-------------| +| `service_name` | The name of the Cloud Run service. | +| `service_uri` | The URI of the Cloud Run service. | +| `service_location` | The location/region of the Cloud Run service. | +| `service_account_email` | The email address of the runtime service account. | +| `service_account_name` | The fully-qualified name of the runtime service account. | +| `serverless_neg_id` | The fully qualified ID of the serverless network endpoint group. | diff --git a/infrastructure/modules/compute/main.tf b/infrastructure/modules/compute/main.tf new file mode 100644 index 00000000..2f119cc0 --- /dev/null +++ b/infrastructure/modules/compute/main.tf @@ -0,0 +1,164 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +locals { + + # The format requires: REGION-docker.pkg.dev / PROJECT_ID / PROXY_REPO_ID / GH_ORG / GH_REPO / IMAGE_NAME + # Notice we omit 'ghcr.io/' because the remote repo configuration handles that root domain mapping automatically. + cloud_run_image_url = "${var.region}-docker.pkg.dev/${var.project_id}/${var.ar_repo_id}/${var.github_org_or_user}/${var.github_repo_name}/backend:${var.image_tag}" + + + # Merge hardcoded standard env vars with user-provided ones + all_env_vars = merge( + { + "INSTANCE_CONNECTION_NAME" = var.cloud_sql_connection_name + "DB_HOST" = "/cloudsql/${var.cloud_sql_connection_name}" + "DB_NAME" = var.db_name + "DB_USER" = var.db_user + "BACKEND_SERVICE_ACCOUNT_EMAIL" = google_service_account.run_sa.email + }, + var.container_env_vars + ) + + # Merge hardcoded secrets with user-provided ones + all_secrets = merge( + { + "DB_PASS" = var.db_secret_id + }, + var.runtime_secrets + ) +} + +resource "google_cloud_run_v2_service" "backend" { + name = var.service_name + location = var.gcp_region + custom_audiences = var.custom_audiences + deletion_protection = false + + # Lock network to the Load Balancer ONLY + ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" + + template { + service_account = google_service_account.run_sa.email + + # Workaround for "Domain Restricted Sharing" org policies + annotations = { + "run.googleapis.com/invoker-iam-disabled" = "true" + } + + volumes { + name = "cloudsql" + cloud_sql_instance { + instances = [var.cloud_sql_connection_name] + } + } + + containers { + image = local.cloud_run_image_url + + resources { + limits = { + cpu = var.cpu + memory = var.memory + } + } + + # non secret env vars + dynamic "env" { + for_each = local.all_env_vars + content { + name = env.key + value = env.value + } + } + + # secrets + dynamic "env" { + for_each = local.all_secrets + content { + name = env.key # The ENV_VAR_NAME + value_source { + secret_key_ref { + secret = env.value # The SECRET_NAME + version = "latest" + } + } + } + } + + volume_mounts { + name = "cloudsql" + mount_path = "/cloudsql" + } + + # Startup and Liveness Probes + startup_probe { + http_get { + path = "/health/ready" # Your API's health endpoint + port = 8080 + } + initial_delay_seconds = 2 + timeout_seconds = 1 + failure_threshold = 3 + } + + liveness_probe { + http_get { + path = "/health/live" + } + period_seconds = 10 + } + + } + scaling { + min_instance_count = var.scaling_min_instances + max_instance_count = var.scaling_max_instances + } + } + + lifecycle { + ignore_changes = [template[0].containers[0].image, client, client_version] + } +} + +resource "google_service_account" "run_sa" { + account_id = "${var.resource_prefix}-${var.environment}-run" + display_name = "SA for ${var.service_name} (${var.environment}) Runtime" +} + +resource "google_project_iam_member" "run_sa_project_bindings" { + for_each = toset(var.run_sa_project_roles) + + project = var.project_id + role = each.value + member = "serviceAccount:${google_service_account.run_sa.email}" +} + +resource "google_service_account_iam_member" "run_sa_act_as_self" { + service_account_id = google_service_account.run_sa.name + role = "roles/iam.serviceAccountUser" + member = "serviceAccount:${google_service_account.run_sa.email}" +} + +# The Serverless NEG targeting this specific service +resource "google_compute_region_network_endpoint_group" "serverless_neg" { + name = "${var.resource_prefix}-${var.environment}-neg" + project = var.project_id + region = var.region + network_endpoint_type = "SERVERLESS" + + cloud_run { + service = google_cloud_run_v2_service.backend.name + } +} \ No newline at end of file diff --git a/infrastructure/modules/compute/outputs.tf b/infrastructure/modules/compute/outputs.tf new file mode 100644 index 00000000..e1bf63bd --- /dev/null +++ b/infrastructure/modules/compute/outputs.tf @@ -0,0 +1,43 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +output "service_name" { + description = "The name of the Cloud Run service." + value = google_cloud_run_v2_service.backend.name +} + +output "service_uri" { + description = "The URI of the Cloud Run service." + value = google_cloud_run_v2_service.backend.uri +} + +output "service_location" { + description = "The location/region of the Cloud Run service." + value = google_cloud_run_v2_service.backend.location +} + +output "service_account_email" { + description = "The email address of the runtime service account." + value = google_service_account.run_sa.email +} + +output "service_account_name" { + description = "The fully-qualified name of the runtime service account." + value = google_service_account.run_sa.name +} + +output "serverless_neg_id" { + description = "The fully qualified ID of the serverless network endpoint group." + value = google_compute_region_network_endpoint_group.serverless_neg.id +} \ No newline at end of file diff --git a/infrastructure/modules/compute/variables.tf b/infrastructure/modules/compute/variables.tf new file mode 100644 index 00000000..5f0c5aac --- /dev/null +++ b/infrastructure/modules/compute/variables.tf @@ -0,0 +1,140 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +variable "project_id" { + type = string + description = "The GCP project ID." +} + +variable "region" { + type = string + description = "The GCP region where resources will be created." +} + +variable "environment" { + type = string + description = "The deployment environment (e.g., development, staging, production)." +} + +variable "resource_prefix" { + type = string + description = "Prefix to be used for resource naming." +} + +variable "service_name" { + type = string + description = "The name of the Cloud Run service." +} + +variable "image_url" { + type = string + description = "The URL of the container image to deploy." +} + +variable "ar_repo_id" { + type = string + description = "The Artifact Registry repository ID." +} + +variable "github_org_or_user" { + type = string + description = "The GitHub organization or user name." +} + +variable "github_repo_name" { + type = string + description = "The GitHub repository name." +} + +variable "image_tag" { + type = string + description = "The image tag to deploy." +} + +variable "custom_audiences" { + type = list(string) + description = "Custom audiences for the Cloud Run service." + default = [] +} + +variable "cloud_sql_connection_name" { + type = string + description = "The connection name of the Cloud SQL instance to mount." +} + +variable "db_name" { + type = string + description = "The name of the database to connect to." +} + +variable "db_user" { + type = string + description = "The database user for connection." +} + +variable "db_secret_id" { + type = string + description = "The Secret Manager secret ID containing the database password." +} + +variable "cpu" { + type = string + description = "CPU limit for the Cloud Run container." + default = "1000m" +} + +variable "memory" { + type = string + description = "Memory limit for the Cloud Run container." + default = "512Mi" +} + +variable "container_env_vars" { + type = map(string) + description = "Map of non-secret environment variables for the container." + default = {} +} + +variable "runtime_secrets" { + type = map(string) + description = "Map of environment variable names to Secret Manager secret names for runtime secrets." + default = {} +} + +variable "scaling_min_instances" { + type = number + description = "Minimum number of container instances." + default = 0 +} + +variable "scaling_max_instances" { + type = number + description = "Maximum number of container instances." + default = 10 +} + +variable "run_sa_project_roles" { + type = list(string) + description = "List of IAM roles to assign to the Cloud Run service account at the project level." + default = [ + "roles/aiplatform.user", + "roles/storage.objectAdmin", + "roles/firebase.developAdmin", + "roles/iam.serviceAccountTokenCreator", + "roles/cloudsql.client", # for Cloud SQL Auth Proxy + "roles/workflows.editor", + "roles/workflows.invoker", + "roles/secretmanager.secretAccessor", + ] +} diff --git a/infrastructure/modules/database/README.md b/infrastructure/modules/database/README.md new file mode 100644 index 00000000..cd540518 --- /dev/null +++ b/infrastructure/modules/database/README.md @@ -0,0 +1,60 @@ +# Database Module (Cloud SQL) + +This module creates a Cloud SQL Postgres instance with Private Services Access (Private IP only), a default database, and a default user. It also stores the generated password in Secret Manager. + +## Features + +- Creates Cloud SQL instance with Private IP. +- Generates random password and stores it in Secret Manager securely (write-only, not in state). +- Enables IAM authentication. +- Configures backups and point-in-time recovery. + +## Usage + +```hcl +module "database" { + source = "./modules/database" + + project_id = "my-project-id" + region = "us-central1" + resource_prefix = "cstudio" + environment = "dev" + vpc_id = module.network.network_id + + # Ensure peering is ready + psa_connection_dependency = module.network.peering_completed +} +``` + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| `project_id` | The ID of the project in which resources will be created. | `string` | n/a | yes | +| `region` | The region in which resources will be created. | `string` | n/a | yes | +| `resource_prefix` | Standard naming prefix assigned to the deployment. | `string` | n/a | yes | +| `environment` | Deployment environment identifier. | `string` | n/a | yes | +| `vpc_id` | The VPC network ID where the Cloud SQL instance will be connected. | `string` | n/a | yes | +| `psa_connection_dependency` | Dependency on the Private Services Access connection. | `any` | `null` | no | +| `database_version` | The database version to use. | `string` | `"POSTGRES_18"` | no | +| `db_tier` | The machine tier/type for the Cloud SQL instance. | `string` | `"db-c4a-highmem-4"` | no | +| `db_availability_type` | The availability type (ZONAL or REGIONAL). | `string` | `"REGIONAL"` | no | +| `initial_disk_size` | The initial disk size in GB. | `number` | `10` | no | +| `max_disk_size` | The maximum disk size in GB for auto-resizing. | `number` | `100` | no | +| `labels` | A map of user labels to assign to the instance. | `map(string)` | `{}` | no | +| `db_name` | The name of the default database to create. | `string` | `"creative_studio"` | no | +| `db_user` | The name of the default database user to create. | `string` | `"app_user"` | no | +| `db_password_version` | The version integer for the database user password. | `number` | `1` | no | +| `deletion_protection` | Whether to enable deletion protection. | `bool` | `false` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| `instance_name` | The name of the Cloud SQL instance. | +| `instance_connection_name` | The connection name of the Cloud SQL instance. | +| `private_ip_address` | The private IP address assigned to the Cloud SQL instance. | +| `database_name` | The name of the created default database. | +| `user_name` | The name of the created database user. | +| `secret_id` | The ID of the Secret Manager secret storing the password. | +| `secret_name` | The resource name of the Secret Manager secret. | diff --git a/infrastructure/modules/database/main.tf b/infrastructure/modules/database/main.tf new file mode 100644 index 00000000..d1b97ecb --- /dev/null +++ b/infrastructure/modules/database/main.tf @@ -0,0 +1,107 @@ +# This generates the password in memory during the Terraform run. +# It is immediately discarded after the run completes. +ephemeral "random_password" "db_pass" { + length = 24 + special = true +} + +resource "random_id" "db_name_suffix" { + byte_length = 4 +} + +# --- The Secret Manager Container --- +resource "google_secret_manager_secret" "db_secret" { + secret_id = "${var.resource_prefix}-${var.environment}-db-password" + project = var.project_id + + replication { + user_managed { + replicas { + location = var.region + } + } + } +} + +resource "google_secret_manager_secret_version" "db_secret_version" { + secret = google_secret_manager_secret.db_secret.id + + # Using a write-only argument prevents the password + # from being captured in the terraform.tfstate file. + secret_data_wo = ephemeral.random_password.db_pass.result +} + +resource "google_sql_database_instance" "default" { + name = "${var.resource_prefix}-${var.environment}-db-${random_id.db_name_suffix.hex}" + database_version = var.database_version + region = var.region + project = var.project_id + + # Ensure networking PSA is established before instance creation + depends_on = [var.psa_connection_dependency] + + settings { + tier = var.db_tier # "db-perf-optimized-N-2" + availability_type = var.db_availability_type + + # Enable IAM Authentication for better security + database_flags { + name = "cloudsql.iam_authentication" + value = "on" + } + + # --- Storage Flexibility --- + disk_type = "PD_SSD" + disk_size = var.initial_disk_size + disk_autoresize = true # Allows growth as needed + disk_autoresize_limit = var.max_disk_size # Prevents unlimited expansion/billing surprises + + backup_configuration { + enabled = true + point_in_time_recovery_enabled = true + location = var.region + } + + # --- Network Isolation --- + ip_configuration { + ipv4_enabled = false # Force Private IP only + private_network = var.vpc_id + enable_private_path_for_google_cloud_services = true + } + + # --- Enterprise Metadata --- + # Merges common labels (from root) with module-specific labels + user_labels = merge(var.labels, { + component = "database" + managed_by = "terraform" + region = var.region + }) + + } + + deletion_protection = var.deletion_protection + + lifecycle { + # Prevent accidental destruction of production data + prevent_destroy = false + # Ignore disk_size changes if auto-resize has grown the disk beyond the TF value + ignore_changes = [settings[0].disk_size] + } +} + +resource "google_sql_database" "default" { + name = var.db_name + instance = google_sql_database_instance.default.name + project = var.project_id +} + +resource "google_sql_user" "app_user" { + name = var.db_user + instance = google_sql_database_instance.default.name + project = var.project_id + + # We read the ephemeral value while creating the DB user, + # keeping the DB state clean of plaintext passwords. + password_wo = ephemeral.random_password.db_pass.result + password_wo_version = var.db_password_version +} diff --git a/infrastructure/modules/database/outputs.tf b/infrastructure/modules/database/outputs.tf new file mode 100644 index 00000000..5f02e9a1 --- /dev/null +++ b/infrastructure/modules/database/outputs.tf @@ -0,0 +1,48 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +output "instance_name" { + description = "The name of the Cloud SQL instance." + value = google_sql_database_instance.default.name +} + +output "instance_connection_name" { + description = "The connection name of the Cloud SQL instance to be used in connection strings." + value = google_sql_database_instance.default.connection_name +} + +output "private_ip_address" { + description = "The private IP address assigned to the Cloud SQL instance." + value = google_sql_database_instance.default.private_ip_address +} + +output "database_name" { + description = "The name of the created default database." + value = google_sql_database.default.name +} + +output "user_name" { + description = "The name of the created database user." + value = google_sql_user.app_user.name +} + +output "secret_id" { + description = "The ID of the Secret Manager secret storing the database password." + value = google_secret_manager_secret.db_secret.secret_id +} + +output "secret_name" { + description = "The resource name of the Secret Manager secret storing the database password." + value = google_secret_manager_secret.db_secret.name +} diff --git a/infrastructure/modules/database/variables.tf b/infrastructure/modules/database/variables.tf new file mode 100644 index 00000000..6117362a --- /dev/null +++ b/infrastructure/modules/database/variables.tf @@ -0,0 +1,104 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +variable "project_id" { + type = string + description = "The ID of the project in which resources will be created." +} + +variable "region" { + type = string + description = "The region in which resources will be created." +} + +variable "resource_prefix" { + type = string + description = "Standard naming prefix assigned to the deployment." +} + +variable "environment" { + type = string + description = "Deployment environment identifier." +} + +variable "vpc_id" { + type = string + description = "The VPC network ID where the Cloud SQL instance will be connected via Private Services Access." +} + +variable "psa_connection_dependency" { + type = any + description = "Dependency on the Private Services Access connection resource to ensure networking is established before instance creation." + default = null +} + +variable "database_version" { + type = string + description = "The database version to use for the Cloud SQL instance." + default = "POSTGRES_18" +} + +variable "db_tier" { + type = string + description = "The machine tier/type for the Cloud SQL instance." + default = "db-c4a-highmem-4" +} + +variable "db_availability_type" { + type = string + description = "The availability type for the Cloud SQL instance (ZONAL or REGIONAL)." + default = "REGIONAL" +} + +variable "initial_disk_size" { + type = number + description = "The initial disk size in GB for the Cloud SQL instance." + default = 10 +} + +variable "max_disk_size" { + type = number + description = "The maximum disk size in GB for auto-resizing." + default = 100 +} + +variable "labels" { + type = map(string) + description = "A map of user labels to assign to the Cloud SQL instance." + default = {} +} + +variable "db_name" { + type = string + description = "The name of the default database to create." + default = "creative_studio" +} + +variable "db_user" { + type = string + description = "The name of the default database user to create." + default = "app_user" +} + +variable "db_password_version" { + type = number + description = "The version integer for the database user password to track updates." + default = 1 +} + +variable "deletion_protection" { + type = bool + description = "Whether to enable deletion protection on the Cloud SQL instance." + default = false +} diff --git a/infrastructure/modules/gateway/README.md b/infrastructure/modules/gateway/README.md new file mode 100644 index 00000000..cea23939 --- /dev/null +++ b/infrastructure/modules/gateway/README.md @@ -0,0 +1,47 @@ +# Gateway Module (Load Balancer & WAF) + +This module creates a Global HTTP(S) Load Balancer with Serverless Network Endpoint Group (NEG) integration and a Cloud Armor security policy (WAF) with rate limiting and OWASP protection. + +## Features + +- Global HTTP(S) Load Balancer with SSL termination. +- Cloud Armor security policy with: + - Adaptive Protection (DDoS defense). + - Rate limiting. + - OWASP Top 10 protection rules. +- Serverless NEG integration for Cloud Run. + +## Usage + +```hcl +module "gateway" { + source = "./modules/gateway" + + project_id = "my-project-id" + resource_prefix = "cstudio" + environment = "dev" + domain_name = "app.creativestudio.com" + serverless_neg_id = module.compute.serverless_neg_id +} +``` + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| `project_id` | The GCP project ID. | `string` | n/a | yes | +| `resource_prefix` | Standard naming prefix assigned to the deployment. | `string` | n/a | yes | +| `environment` | Deployment environment identifier. | `string` | n/a | yes | +| `domain_name` | The custom domain string linked to the load balancer. | `string` | n/a | yes | +| `serverless_neg_id` | The self-link ID of the serverless NEG. | `string` | n/a | yes | +| `rate_limit_count` | Max requests per minute per IP before rate limiting. | `number` | `100` | no | +| `rate_limit_interval_sec` | Interval in seconds for rate limiting. | `number` | `60` | no | +| `ban_duration_sec` | Duration in seconds to ban abusive IPs. | `number` | `300` | no | +| `enable_cdn` | Whether to enable Cloud CDN on the backend. | `bool` | `false` | no | +| `log_sample_rate` | Sample rate for request logging (0.0 to 1.0). | `number` | `1.0` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| `load_balancer_ip` | The external IP address of the Load Balancer. | diff --git a/infrastructure/modules/gateway/main.tf b/infrastructure/modules/gateway/main.tf new file mode 100644 index 00000000..4dace05f --- /dev/null +++ b/infrastructure/modules/gateway/main.tf @@ -0,0 +1,97 @@ +# --- Cloud Armor (WAF) --- +resource "google_compute_security_policy" "policy" { + name = "${var.resource_prefix}-${var.environment}-waf-policy" + + # Enable Machine Learning DDoS Protection + adaptive_protection_config { + layer_7_ddos_defense_config { + enable = true + rule_visibility = "STANDARD" + } + } + + # Rate Limiting (e.g., max 100 requests per minute per IP) + rule { + action = "rate_based_ban" + priority = 500 + match { + versioned_expr = "SRC_IPS_V1" + config { src_ip_ranges = ["*"] } + } + rate_limit_options { + conform_action = "allow" + exceed_action = "deny(429)" + enforce_on_key = "IP" + rate_limit_threshold { + count = var.rate_limit_count + interval_sec = var.rate_limit_interval_sec + } + ban_duration_sec = var.ban_duration_sec + } + description = "Rate limit abusive traffic" + } + + # Expanded OWASP Protection + rule { + action = "deny(403)" + priority = 1000 + match { + expr { + # Combine SQLi, XSS, and LFI checks + expression = "evaluatePreconfiguredExpr('sqli-v33-stable') || evaluatePreconfiguredExpr('xss-v33-stable') || evaluatePreconfiguredExpr('lfi-v33-stable')" + } + } + description = "Block OWASP Top 10 attacks" + } + + rule { + action = "allow" + priority = 2147483647 + match { + versioned_expr = "SRC_IPS_V1" + config { src_ip_ranges = ["*"] } + } + description = "Default allow" + } +} + +# --- Global Load Balancer --- +module "lb-http" { + source = "terraform-google-modules/lb-http/google//modules/serverless_negs" + version = "~> 12.0" # Always pin versions for production stability + + project = var.project_id + + # Dynamic base name to safely isolate multi-tenant/customer deployments + name = "${var.resource_prefix}-${var.environment}-lb" + + # SSL Configuration (Mandatory Production Custom Domain Setup) + ssl = true + managed_ssl_certificate_domains = [var.domain_name] + https_redirect = true + + backends = { + default = { + description = "Backend routing for Serverless Cloud Run integration" + groups = [ + { + group = var.serverless_neg_id + } + ] + + # Correct parameter mapping for v12.0 caching rules + enable_cdn = var.enable_cdn + security_policy = google_compute_security_policy.policy.name + + # Full audit visibility required by Enterprise InfoSec policies + log_config = { + enable = true + sample_rate = var.log_sample_rate + } + + iap_config = { + enable = false + } + } + } +} \ No newline at end of file diff --git a/infrastructure/modules/gateway/outputs.tf b/infrastructure/modules/gateway/outputs.tf new file mode 100644 index 00000000..89c1ef1c --- /dev/null +++ b/infrastructure/modules/gateway/outputs.tf @@ -0,0 +1,4 @@ +output "load_balancer_ip" { + description = "The external IP address of the Load Balancer." + value = module.lb-http.external_ip +} diff --git a/infrastructure/modules/gateway/variables.tf b/infrastructure/modules/gateway/variables.tf new file mode 100644 index 00000000..b4ff0046 --- /dev/null +++ b/infrastructure/modules/gateway/variables.tf @@ -0,0 +1,54 @@ +variable "project_id" { + type = string + description = "The customer's Google Cloud project ID hosting the gateway infrastructure." +} + +variable "resource_prefix" { + type = string + description = "Standard naming prefix assigned to the customer deployment (e.g., 'cstudio')." +} + +variable "environment" { + type = string + description = "Deployment environment identifier (e.g., 'eval', 'staging', 'prod')." +} + +variable "domain_name" { + type = string + description = "The mandated custom domain string linked to the external load balancer." +} + +variable "serverless_neg_id" { + type = string + description = "The fully qualified self-link resource ID of the serverless Network Endpoint Group." +} + +variable "rate_limit_count" { + type = number + description = "Max requests per minute per IP before rate limiting applies." + default = 100 +} + +variable "rate_limit_interval_sec" { + type = number + description = "Interval in seconds for rate limiting." + default = 60 +} + +variable "ban_duration_sec" { + type = number + description = "Duration in seconds to ban abusive IPs." + default = 300 +} + +variable "enable_cdn" { + type = bool + description = "Whether to enable Cloud CDN on the backend." + default = false +} + +variable "log_sample_rate" { + type = number + description = "Sample rate for request logging (0.0 to 1.0)." + default = 1.0 +} diff --git a/infrastructure/modules/network/README.md b/infrastructure/modules/network/README.md new file mode 100644 index 00000000..1fb8b638 --- /dev/null +++ b/infrastructure/modules/network/README.md @@ -0,0 +1,43 @@ +# Network Module (VPC & Peering) + +This module creates a custom VPC network, a dedicated subnet for Cloud Run Direct VPC Egress, and establishes Private Services Access (VPC Peering) with Google-managed services (for Cloud SQL). It also adds baseline firewall rules to allow internal traffic. + +## Features + +- Custom VPC network with regional routing. +- Dedicated subnet for Cloud Run egress. +- Private Services Access connection for Cloud SQL. +- Firewall rule to allow internal traffic from Cloud Run subnet. + +## Usage + +```hcl +module "network" { + source = "./modules/network" + + project_id = "my-project-id" + region = "us-central1" + resource_prefix = "cstudio" + environment = "dev" + cloud_run_cidr = "10.0.0.0/26" +} +``` + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| `project_id` | The customer's Google Cloud project ID. | `string` | n/a | yes | +| `region` | The primary GCP region for network subnets. | `string` | n/a | yes | +| `resource_prefix` | Standard naming prefix assigned to the deployment. | `string` | n/a | yes | +| `environment` | Deployment environment identifier. | `string` | n/a | yes | +| `cloud_run_cidr` | Dedicated CIDR range for Cloud Run Direct VPC egress. | `string` | `"10.0.0.0/26"` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| `network_id` | The fully qualified ID of the VPC network. | +| `network_name` | The simple name of the VPC network. | +| `cloud_run_subnet_name` | The name of the subnet allocated for Cloud Run. | +| `peering_completed` | Dependency trigger string confirming Private Services Access peering is active. | diff --git a/infrastructure/modules/network/main.tf b/infrastructure/modules/network/main.tf new file mode 100644 index 00000000..ce256d43 --- /dev/null +++ b/infrastructure/modules/network/main.tf @@ -0,0 +1,64 @@ +# --- Custom VPC Network --- +resource "google_compute_network" "vpc" { + name = "${var.resource_prefix}-${var.environment}-vpc" + project = var.project_id + auto_create_subnetworks = false + routing_mode = "REGIONAL" +} + +# --- Subnet for Cloud Run Direct VPC Egress --- +# Cloud Run requires a dedicated subnet to inject private traffic into the VPC. +resource "google_compute_subnetwork" "cloud_run_subnet" { + name = "${var.resource_prefix}-${var.environment}-run-subnet" + project = var.project_id + network = google_compute_network.vpc.id + region = var.region + ip_cidr_range = var.cloud_run_cidr + + # Crucial enterprise guardrail: allows internal routing to Google APIs without public IPs + private_ip_google_access = true +} + +# --- Private Services Access (Cloud SQL Peering) --- +# Reserve a private IP block strictly for Google-managed services peering. +resource "google_compute_global_address" "private_service_access" { + name = "${var.resource_prefix}-${var.environment}-psa-range" + project = var.project_id + network = google_compute_network.vpc.id + purpose = "VPC_PEERING" + address_type = "INTERNAL" + prefix_length = 24 +} + +# Establish the private network peering connection with Google Services. +resource "google_service_networking_connection" "private_vpc_connection" { + network = google_compute_network.vpc.id + service = "servicenetworking.googleapis.com" + reserved_peering_ranges = [google_compute_global_address.private_service_access.name] +} + +# --- Baseline Firewall Rules --- +# Custom VPCs have an implied deny-all ingress rule. We explicitly permit internal +# traffic originating from our Cloud Run subnet to enable database reachability. +resource "google_compute_firewall" "allow_internal_egress" { + name = "${var.resource_prefix}-${var.environment}-allow-internal" + project = var.project_id + network = google_compute_network.vpc.id + + allow { + protocol = "tcp" + } + + allow { + protocol = "udp" + } + + source_ranges = [var.cloud_run_cidr] + description = "Allow Cloud Run internal subnet traffic to traverse the VPC" + + # Apply audit logging to internal cross-resource connections + log_config { + metadata = "INCLUDE_ALL_METADATA" + } +} + diff --git a/infrastructure/modules/network/outputs.tf b/infrastructure/modules/network/outputs.tf new file mode 100644 index 00000000..06c59c3c --- /dev/null +++ b/infrastructure/modules/network/outputs.tf @@ -0,0 +1,22 @@ +output "network_id" { + description = "The fully qualified ID of the VPC network." + value = google_compute_network.vpc.id +} + +output "network_name" { + description = "The simple name of the VPC network." + value = google_compute_network.vpc.name +} + +output "cloud_run_subnet_name" { + description = "The name of the subnet allocated for Cloud Run Direct VPC Egress." + value = google_compute_subnetwork.cloud_run_subnet.name +} + +# Architectural Guardrail: Resolves classic GCP Terraform deployment race conditions. +# Cloud SQL modules must explicitly reference this output in their 'depends_on' block +# to guarantee the peering connection finishes provisioning before database creation begins. +output "peering_completed" { + description = "Dependency trigger string confirming Private Services Access peering is active." + value = google_service_networking_connection.private_vpc_connection.id +} \ No newline at end of file diff --git a/infrastructure/modules/network/variables.tf b/infrastructure/modules/network/variables.tf new file mode 100644 index 00000000..2123603e --- /dev/null +++ b/infrastructure/modules/network/variables.tf @@ -0,0 +1,25 @@ +variable "project_id" { + type = string + description = "The customer's Google Cloud project ID." +} + +variable "region" { + type = string + description = "The primary GCP region for network subnets." +} + +variable "resource_prefix" { + type = string + description = "Standard naming prefix assigned to the customer deployment." +} + +variable "environment" { + type = string + description = "Deployment environment identifier (e.g., 'dev', 'stg', 'prd')." +} + +variable "cloud_run_cidr" { + type = string + description = "Dedicated CIDR range for Cloud Run Direct VPC egress (minimum /28 recommended)." + default = "10.0.0.0/26" +} diff --git a/infrastructure/network.tf b/infrastructure/network.tf new file mode 100644 index 00000000..315d6561 --- /dev/null +++ b/infrastructure/network.tf @@ -0,0 +1,21 @@ +module "network" { + source = "./modules/network" + + project_id = var.project_id + region = var.region + resource_prefix = var.resource_prefix + environment = var.environment + cloud_run_cidr = var.cloud_run_cidr +} + +module "gateway" { + source = "./modules/gateway" + + project_id = var.project_id + resource_prefix = var.resource_prefix + environment = var.environment + domain_name = var.domain_name + + # Connects the serverless NEG exposed from the compute bundle + serverless_neg_id = module.compute.serverless_neg_id +} diff --git a/infrastructure/outputs.tf b/infrastructure/outputs.tf new file mode 100644 index 00000000..21d05589 --- /dev/null +++ b/infrastructure/outputs.tf @@ -0,0 +1,45 @@ +output "firebase_dns_verification_records" { + description = "The target DNS records you must add to your domain registrar to verify ownership and activate SSL." + # Safely handle the output depending on whether the resource count is 0 or 1 + value = length(google_firebase_hosting_custom_domain.custom_domain) > 0 ? ( + google_firebase_hosting_custom_domain.custom_domain[0].required_dns_updates + ) : null +} + +output "cloud_sql_connection_name" { + description = "The connection name of the Cloud SQL instance." + value = module.database.instance_connection_name +} + +output "db_name" { + description = "The name of the database." + value = module.database.database_name +} + +output "db_user" { + description = "The database user name." + value = module.database.user_name +} + +output "db_secret_id" { + description = "Secret Manager secret ID for DB Password." + value = module.database.secret_id +} + +output "frontend_service_url" { + description = "The default Firebase Hosting URL." + value = "https://${google_firebase_hosting_site.frontend.site_id}.web.app" +} + +output "backend_service_url" { + description = "The URL of the backend service." + value = module.compute.service_uri +} + +output "cloud_run_subnet_name" { + description = "The subnet name allocated for Cloud Run Direct VPC Egress." + value = module.network.cloud_run_subnet_name +} + + + diff --git a/infrastructure/services.tf b/infrastructure/services.tf new file mode 100644 index 00000000..aaf5ebfb --- /dev/null +++ b/infrastructure/services.tf @@ -0,0 +1,11 @@ +# --- Enable the required Google Cloud APIs --- +resource "google_project_service" "apis" { + # Use a for_each loop to enable each API from the variable list + for_each = toset(var.apis_to_enable) + + project = var.project_id + service = each.key + + # This prevents Terraform from disabling APIs when you run `terraform destroy` + disable_on_destroy = false +} diff --git a/infrastructure/variables.tf b/infrastructure/variables.tf new file mode 100644 index 00000000..5b459111 --- /dev/null +++ b/infrastructure/variables.tf @@ -0,0 +1,85 @@ +variable "project_id" { + type = string +} + +variable "region" { + type = string +} + +variable "environment" { + type = string + default = "development" +} + +variable "apis_to_enable" { + type = list(string) + default = [ + "serviceusage.googleapis.com", # Required to enable other APIs + "iam.googleapis.com", # Required for IAM management + "cloudbuild.googleapis.com", # Required for Cloud Build + "artifactregistry.googleapis.com", # Required for Artifact Registry + "run.googleapis.com", # Required for Cloud Run + "cloudresourcemanager.googleapis.com", + "compute.googleapis.com", + "cloudfunctions.googleapis.com", + "iamcredentials.googleapis.com", + "aiplatform.googleapis.com", + "firestore.googleapis.com", + "texttospeech.googleapis.com", + "workflows.googleapis.com" + ] +} + +variable "custom_domain" { + type = string + description = "Optional custom domain name to link to Firebase Hosting (e.g., 'app.creativestudio.com'). Leave blank to skip." + default = "" +} + +variable "application_secrets" { + type = set(string) + description = "The list of application secret identifiers required by the backend application layer." + default = ["database_url", "firebase_jwt_secret", "third_party_api_key"] +} + +variable "resource_prefix" { + type = string + description = "Standard naming prefix assigned to the deployment." +} + +variable "cloud_run_cidr" { + type = string + description = "Dedicated CIDR range for Cloud Run Direct VPC egress." + default = "10.0.0.0/26" +} + +variable "backend_image_name" { + type = string + description = "The name of the backend container image." + default = "backend" +} + +variable "backend_image_tag" { + type = string + description = "The tag of the backend container image." + default = "latest" +} + +variable "domain_name" { + type = string + description = "The custom domain name for the Load Balancer." +} + +variable "firebase_site_id" { + type = string + description = "The Firebase Hosting Site ID. If empty, defaults to the project ID." + default = "" +} + +variable "labels" { + type = map(string) + description = "Standard resource labels to apply across resources." + default = {} +} + +