From 40fe69ed65f5c7dd5f463096ef73beca77d8097d Mon Sep 17 00:00:00 2001 From: Enciphered Date: Mon, 4 May 2026 23:23:02 +0500 Subject: [PATCH] Intial commit for deployment script p2 --- .gitignore | 2 +- GCR/.env.example | 38 +++ GCR/.gitignore | 2 + GCR/Dockerfile | 98 +++++++ GCR/README.md | 384 ++++++++++++++++++++++++++++ GCR/docker-compose.yml | 30 +++ GCR/entrypoint.sh | 5 + GCR/run-all.ps1 | 185 ++++++++++++++ GCR/run-all.sh | 162 ++++++++++++ GCR/scripts/00-install-gcloud.ps1 | 54 ++++ GCR/scripts/00-install-gcloud.sh | 88 +++++++ GCR/scripts/01-login.ps1 | 62 +++++ GCR/scripts/01-login.sh | 58 +++++ GCR/scripts/02-setup-project.ps1 | 126 +++++++++ GCR/scripts/02-setup-project.sh | 117 +++++++++ GCR/scripts/03-create-secrets.ps1 | 122 +++++++++ GCR/scripts/03-create-secrets.sh | 101 ++++++++ GCR/scripts/04-deploy.ps1 | 194 ++++++++++++++ GCR/scripts/04-deploy.sh | 180 +++++++++++++ Htmx.ApiDemo/wwwroot/css/output.css | 2 +- 20 files changed, 2008 insertions(+), 2 deletions(-) create mode 100644 GCR/.env.example create mode 100644 GCR/.gitignore create mode 100644 GCR/Dockerfile create mode 100644 GCR/README.md create mode 100644 GCR/docker-compose.yml create mode 100755 GCR/entrypoint.sh create mode 100644 GCR/run-all.ps1 create mode 100644 GCR/run-all.sh create mode 100644 GCR/scripts/00-install-gcloud.ps1 create mode 100755 GCR/scripts/00-install-gcloud.sh create mode 100644 GCR/scripts/01-login.ps1 create mode 100755 GCR/scripts/01-login.sh create mode 100644 GCR/scripts/02-setup-project.ps1 create mode 100755 GCR/scripts/02-setup-project.sh create mode 100644 GCR/scripts/03-create-secrets.ps1 create mode 100644 GCR/scripts/03-create-secrets.sh create mode 100644 GCR/scripts/04-deploy.ps1 create mode 100755 GCR/scripts/04-deploy.sh diff --git a/.gitignore b/.gitignore index b759220..7ef65da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ bin obj node_modules -package-lock.json \ No newline at end of file +.env \ No newline at end of file diff --git a/GCR/.env.example b/GCR/.env.example new file mode 100644 index 0000000..13beda4 --- /dev/null +++ b/GCR/.env.example @@ -0,0 +1,38 @@ +# Environment variables for Cloud Run deployment +# Copy this file to GCR/.env and fill in your values. +# GCR/.env is gitignored — never commit real credentials. +# +# IMPORTANT: Sensitive values (MongoDB connection string, API keys, passwords) +# should NOT be stored here. Use Google Cloud Secret Manager instead. +# See GCR/README.md for the Secret Manager setup workflow. + +# ═══════════════════════════════════════════════════════════════════════════ +# NON-SENSITIVE CONFIG — safe to commit to version control +# ═══════════════════════════════════════════════════════════════════════════ + +# Your GCP project ID (lowercase letters, digits, hyphens; 6–30 chars) +GCP_PROJECT_ID=htmx-demo + +# Region to deploy the Cloud Run service and Artifact Registry repository +# Recommended regions: us-central1, us-east1, europe-west1, asia-east1 +GCP_REGION=asia-east1 + +# Name of the Artifact Registry Docker repository (created by 02-setup-project.sh) +GCP_REPOSITORY=htmx-demo + +# Cloud Run service name +SERVICE_NAME=htmx-demo-app + +# Name of the MongoDB database to use (not sensitive, just a name) +MONGODB_DATABASE_NAME=HtmxAppDb + +# ═══════════════════════════════════════════════════════════════════════════ +# SENSITIVE CONFIG — stored in Google Cloud Secret Manager, never here! +# ═══════════════════════════════════════════════════════════════════════════ +# +# MongoDB Connection String +# Store in Secret Manager as: mongodb-connection-string +# Create with: printf '%s' "mongodb+srv://user:pass@..." | \ +# gcloud secrets create mongodb-connection-string --data-file=- +# +# The deploy script will inject this automatically as ConnectionStrings__DefaultConnection diff --git a/GCR/.gitignore b/GCR/.gitignore new file mode 100644 index 0000000..cc660ba --- /dev/null +++ b/GCR/.gitignore @@ -0,0 +1,2 @@ +# Never commit real credentials +.env diff --git a/GCR/Dockerfile b/GCR/Dockerfile new file mode 100644 index 0000000..e54cf70 --- /dev/null +++ b/GCR/Dockerfile @@ -0,0 +1,98 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Stage 1 — npm install (Tailwind CLI) +# The .NET SDK stage needs node_modules present before running dotnet publish +# because the MSBuild Tailwind target calls `npx @tailwindcss/cli` during build. +# +# We use the official node:24-slim image here. This means the npm that ships +# with Node 24 (npm 10.x) is used as-is — we deliberately do NOT run +# `npm install -g npm@latest` anywhere. Running a global npm self-upgrade +# inside a Debian container is a known reliability hazard: npm replaces its +# own running binaries mid-flight, which can cause EBUSY / ENOENT failures +# that corrupt the install. The bundled npm is current enough. +# ───────────────────────────────────────────────────────────────────────────── +FROM node:24-slim AS npm-install + +WORKDIR /npm +COPY Htmx.ApiDemo/package.json . +# npm ci requires package-lock.json; if it doesn't exist yet, run +# `npm install` locally first to generate it, then commit it to the repo. +COPY Htmx.ApiDemo/package-lock.json* ./ +# ci is preferred over install in CI/Docker contexts: respects package-lock, +# clean installs, and is faster. +RUN npm ci + +# ───────────────────────────────────────────────────────────────────────────── +# Stage 2 — AOT publish +# Uses the full .NET 10 SDK image. Node/npx must also be present here so the +# Tailwind MSBuild target can run. We install Node 24 from NodeSource using +# the official setup script and then immediately install nodejs via apt — no +# subsequent `npm install -g npm` step, for the same reason as above. +# ───────────────────────────────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS publish + +# Install Node.js 24 (required by the Tailwind MSBuild target at publish time). +# We download the NodeSource setup script to a file first so we can inspect it +# if needed, then run it. Using `| bash -` directly is convenient but hides +# the script from audit — the two-step form is safer in CI/CD contexts. +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl ca-certificates && \ + curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/nodesource_setup.sh && \ + bash /tmp/nodesource_setup.sh && \ + apt-get install -y --no-install-recommends nodejs && \ + rm /tmp/nodesource_setup.sh && \ + rm -rf /var/lib/apt/lists/* +# Intentionally no `npm install -g npm` — see Stage 1 note above. + +WORKDIR /src + +# Copy solution and project files first so NuGet restore is cached separately +COPY Htmx.slnx . +COPY Htmx.ApiDemo/Htmx.ApiDemo.csproj Htmx.ApiDemo/ +COPY Htmx.SourceGenerator/Htmx.SourceGenerator.csproj Htmx.SourceGenerator/ + +RUN dotnet restore Htmx.ApiDemo/Htmx.ApiDemo.csproj + +# Bring in the pre-installed node_modules from Stage 1. +# These were installed with `npm ci` on Node 24 — no npm upgrade was performed. +COPY --from=npm-install /npm/node_modules Htmx.ApiDemo/node_modules + +# Copy the rest of the source +COPY . . + +# AOT publish — output goes to /publish +RUN dotnet publish Htmx.ApiDemo/Htmx.ApiDemo.csproj \ + -c Release \ + --no-restore \ + -o /publish + +# ───────────────────────────────────────────────────────────────────────────── +# Stage 3 — Runtime image +# runtime-deps provides the native library dependencies (libc, libssl, libicu) +# that the AOT binary links against at runtime — no .NET runtime needed. +# ───────────────────────────────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0 AS runtime + +# Run as non-root for security hardening (recommended by Cloud Run docs) +RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser + +WORKDIR /app + +COPY --from=publish /publish . + +# Ensure the binary is executable +RUN chmod +x ./Htmx.ApiDemo + +# Cloud Run injects PORT (default 8080). +# ASP.NET Core reads ASPNETCORE_HTTP_PORTS, not PORT directly, so we set it. +# The entrypoint script below maps $PORT → ASPNETCORE_HTTP_PORTS at startup. +COPY GCR/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Transfer ownership so the app can write temp files if needed +RUN chown -R appuser:appgroup /app + +USER appuser + +EXPOSE 8080 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/GCR/README.md b/GCR/README.md new file mode 100644 index 0000000..68f5e10 --- /dev/null +++ b/GCR/README.md @@ -0,0 +1,384 @@ +# Deploying to Google Cloud Run + +This folder contains everything needed to deploy the Htmx app to Google Cloud Run — completely isolated from the application code. + +## Folder structure + +``` +GCR/ +├── .env.example ← copy to .env and fill in your values +├── Dockerfile ← multi-stage AOT build (Linux/amd64) +├── entrypoint.sh ← maps Cloud Run's PORT var to ASP.NET Core +├── docker-compose.yml ← Cloud Run service definition (used by gcloud) +├── run-all.sh ← smart Linux runner (checks + prompts) +├── run-all.ps1 ← smart Windows runner (checks + prompts) +└── scripts/ + ├── 00-install-gcloud.sh / .ps1 ← install Google Cloud CLI + ├── 01-login.sh / .ps1 ← authenticate + configure Docker + ├── 02-setup-project.sh / .ps1 ← one-time GCP project setup + ├── 03-create-secrets.sh / .ps1 ← manage MongoDB secret + └── 04-deploy.sh / .ps1 ← build, push, and deploy +``` + +`.sh` scripts are for **Linux**. `.ps1` scripts are for **Windows** (PowerShell 5.1+). + +### One-command flow (recommended) + +Instead of running each step manually, use the root runner. It checks each step, +shows completed items, and prompts to run only missing steps. + +**Linux:** +```bash +bash GCR/run-all.sh +# non-interactive (auto-run missing steps): +bash GCR/run-all.sh --yes +``` + +**Windows:** +```powershell +.\GCR\run-all.ps1 +# non-interactive (auto-run missing steps): +.\GCR\run-all.ps1 -Yes +``` + +## Security on Untrusted Machines + +Do **not** run these scripts on machines you don't control unless absolutely necessary. + +Why this matters: +1. The scripts grant project-level IAM roles to your user, including `roles/secretmanager.admin`. +2. `gcloud auth login` stores local credentials/tokens that can be reused if the machine is compromised. +3. Docker auth is configured for Artifact Registry and may persist in local Docker config. +4. A local `GCR/.env` file contains project identifiers and deployment metadata. + +Minimum cleanup if you ever used a shared/untrusted machine: + +1. Revoke IAM roles from your user account in the GCP project. +2. Revoke local gcloud credentials and clear config. +3. Remove Docker credential entries for Artifact Registry. +4. Delete local `GCR/.env` and any temporary files. + +Example role cleanup (Linux/macOS shell): +```bash +USER_EMAIL="your-user@company.com" +PROJECT_ID="your-project-id" + +for ROLE in \ + roles/run.developer \ + roles/artifactregistry.writer \ + roles/iam.serviceAccountUser \ + roles/secretmanager.admin \ + roles/secretmanager.secretAccessor \ + roles/secretmanager.secretVersionAdder; do + gcloud projects remove-iam-policy-binding "$PROJECT_ID" \ + --member="user:$USER_EMAIL" \ + --role="$ROLE" \ + --quiet +done +``` + +Credential cleanup: +```bash +gcloud auth revoke --all +gcloud config configurations delete default --quiet || true +``` + +Credential cleanup (Windows PowerShell): +```powershell +gcloud auth revoke --all +gcloud config configurations delete default --quiet +``` + +Prefer a dedicated personal/admin workstation, or use a tightly scoped CI service account instead of broad user credentials. + +--- + +## Step 0 — Configure your .env file + +Copy the example and fill it in: + +```bash +# Linux +cp GCR/.env.example GCR/.env +``` + +```powershell +# Windows +Copy-Item GCR\.env.example GCR\.env +``` + +Open `GCR/.env` in any editor and set: + +| Variable | Description | Example | +|---|---|---| +| `GCP_PROJECT_ID` | Your GCP project ID | `my-htmx-project` | +| `GCP_REGION` | Cloud Run region | `us-central1` | +| `GCP_REPOSITORY` | Artifact Registry repo name | `htmx` | +| `SERVICE_NAME` | Cloud Run service name | `htmx-app` | +| `MONGODB_DATABASE_NAME` | Database name | `HtmxAppDb` | + +> **Security note:** `GCR/.env` is gitignored. Never commit it. + +> **MongoDB note:** The app connects to MongoDB at startup. Cloud Run containers do not have access to `localhost:27017` — use MongoDB Atlas (cloud-hosted) or a MongoDB instance reachable over the internet/VPC. + +> **Secrets note:** The MongoDB connection string is **not stored in .env**. It's stored securely in Google Cloud Secret Manager. See **Step 4** below. + +--- + +## Step 1 — Install the Google Cloud CLI + +Run **once** on a new machine. + +**Linux:** +```bash +bash GCR/scripts/00-install-gcloud.sh +``` + +**Windows** (PowerShell, run as Administrator): +```powershell +Set-ExecutionPolicy RemoteSigned -Scope CurrentUser # first time only +.\GCR\scripts\00-install-gcloud.ps1 +``` + +After installation, open a new terminal and verify: +``` +gcloud version +``` + +--- + +## Step 2 — Log in + +Authenticates your machine to GCP and configures Docker to push to Artifact Registry. + +**Linux:** +```bash +bash GCR/scripts/01-login.sh +``` + +**Windows:** +```powershell +.\GCR\scripts\01-login.ps1 +``` + +A browser window opens for Google sign-in. Use the account that owns or has access to your GCP project. + +--- + +## Step 3 — Set up the GCP project (one time) + +Enables APIs, creates the Artifact Registry repository, and grants your account the required IAM roles. If billing is not yet linked, the script prompts you to choose a billing account. + +**Linux:** +```bash +bash GCR/scripts/02-setup-project.sh +``` + +**Windows:** +```powershell +.\GCR\scripts\02-setup-project.ps1 +``` + +This is **safe to re-run** — all operations are idempotent. + +### What it enables + +| API | Purpose | +|---|---| +| `run.googleapis.com` | Cloud Run service | +| `artifactregistry.googleapis.com` | Docker image storage | +| `secretmanager.googleapis.com` | Available for future use | +| `cloudresourcemanager.googleapis.com` | IAM and project management | + +### What IAM roles it grants (to your account) + +| Role | Purpose | +|---|---| +| `roles/run.developer` | Deploy and manage Cloud Run services | +| `roles/artifactregistry.writer` | Push container images | +| `roles/iam.serviceAccountUser` | Run the service under a service account | +| `roles/secretmanager.admin` | Create/manage secrets and IAM policies (includes `secretmanager.secrets.create`) | +| `roles/secretmanager.secretAccessor` | Read secret payloads (for validation/access workflows) | +| `roles/secretmanager.secretVersionAdder` | Add/set new secret versions (rotate values safely) | + +--- + +## Step 4 — Create secrets in Google Cloud Secret Manager + +Store the MongoDB connection string securely: + +**Linux:** +```bash +bash GCR/scripts/03-create-secrets.sh +``` + +**Windows:** +```powershell +.\GCR\scripts\03-create-secrets.ps1 +``` + +The script prompts for your MongoDB connection string, creates the secret in Secret Manager, and grants Cloud Run permission to access it. The secret is referenced by name (`mongodb-connection-string`) in the deploy script — never stored in .env. + +This is a **one-time setup**. Re-run only if you need to **update** the MongoDB connection string. + +--- + +## Step 5 — Deploy + +Builds the Docker image, pushes it to Artifact Registry, and deploys to Cloud Run. + +If secrets are missing, the deploy script now performs a pre-check and prompts to run +the secrets setup script before continuing. + +**Linux:** +```bash +bash GCR/scripts/04-deploy.sh +# or with a custom image tag: +bash GCR/scripts/04-deploy.sh v1.0.0 +``` + +**Windows:** +```powershell +.\GCR\scripts\04-deploy.ps1 +# or with a custom image tag: +.\GCR\scripts\04-deploy.ps1 -Tag v1.0.0 +``` + +The script: +1. Checks for (or generates) `Htmx.ApiDemo/package-lock.json` +2. Builds the Docker image from the repo root using `GCR/Dockerfile` +3. Pushes the image to Artifact Registry +4. Deploys to Cloud Run using `GCR/docker-compose.yml` +5. Opens the service to public access (no authentication required) +6. Prints the live service URL + +By default the image tag is the short git commit SHA (e.g. `a3f4b7c`). A timestamp is used if the directory is not a git repo. + +--- + +## How configuration reaches the app + +The app reads configuration from environment variables. Cloud Run injects them at container startup — no config files needed in the image. + +| Environment variable | Maps to | Set by | +|---|---|---| +| `ConnectionStrings__DefaultConnection` | `appsettings.json` → `ConnectionStrings.DefaultConnection` | Secret Manager (via `--set-secrets`) → deploy script | +| `MongoDbName` | `appsettings.json` → `MongoDbName` | `GCR/.env` → deploy script → docker-compose.yml | +| `ASPNETCORE_ENVIRONMENT` | ASP.NET Core environment | `docker-compose.yml` (hardcoded `Production`) | +| `PORT` | Listening port | Injected by Cloud Run (default `8080`) | + +**Secret Manager workflow:** +- Step 4 stores the MongoDB connection string in Cloud Run Secret Manager +- Step 5 (deploy script) injects it via `gcloud run services update --set-secrets=...` +- The container never sees the raw connection string; Cloud Run mounts it as an env var at runtime +- Each time you update the secret, Cloud Run automatically uses the latest version + +The `GCR/entrypoint.sh` script translates Cloud Run's `PORT` variable into `ASPNETCORE_HTTP_PORTS` at container startup, since ASP.NET Core does not read `PORT` directly. + +--- + +## Re-deploying after code changes + +Just run Step 5 again. Each deployment gets a new image tag (git SHA), and Cloud Run creates a new immutable revision. Traffic is shifted to the new revision automatically. + +--- + +## Updating configuration + +### Non-sensitive config (MONGODB_DATABASE_NAME, etc.) + +To change a non-sensitive value without rebuilding: + +**Linux:** +```bash +source GCR/.env +gcloud run services update $SERVICE_NAME \ + --region=$GCP_REGION \ + --update-env-vars "MongoDbName=NewDatabaseName" +``` + +**Windows:** +```powershell +gcloud run services update htmx-app ` + --region=us-central1 ` + --update-env-vars "MongoDbName=NewDatabaseName" +``` + +### Sensitive config (MongoDB connection string) + +To update the MongoDB connection string: + +**Linux:** +```bash +source GCR/.env +# Read from stdin (paste the connection string and press Ctrl+D): +gcloud secrets versions add mongodb-connection-string \ + --data-file=- \ + --project=$GCP_PROJECT_ID +``` + +**Windows:** +```powershell +# Use a temp file to avoid adding trailing newlines to the secret +$connectionString = Read-Host -AsSecureString "Enter MongoDB connection string" +$connectionStringPlainText = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($connectionString)) +$TempFile = [System.IO.Path]::GetTempFileName() +try { + [System.IO.File]::WriteAllText($TempFile, $connectionStringPlainText, [System.Text.Encoding]::UTF8) + gcloud secrets versions add mongodb-connection-string --data-file=$TempFile --project=your-project-id +} finally { + Remove-Item $TempFile -Force -ErrorAction SilentlyContinue +} +``` + +Or re-run the creation script: + +**Linux:** +```bash +bash GCR/scripts/03-create-secrets.sh +``` + +**Windows:** +```powershell +.\GCR\scripts\03-create-secrets.ps1 +``` + +Cloud Run automatically uses the latest secret version on the next container start. + +--- + +## Troubleshooting + +### Docker build fails on `npm ci` + +`npm ci` requires `Htmx.ApiDemo/package-lock.json` to exist. Generate it locally: + +```bash +cd Htmx.ApiDemo && npm install +``` + +Then commit `package-lock.json` to the repository. + +### `gcloud: command not found` after install + +Close and reopen your terminal. The installer adds `gcloud` to `PATH`, but the current shell session won't see it until restarted. + +### `PERMISSION_DENIED` errors during deploy + +Run `02-setup-project` again — it grants the required IAM roles. It is safe to re-run. + +### Cloud Run container crashes on startup + +View logs in the GCP console (Cloud Run → your service → Logs tab), or: + +```bash +gcloud run services logs read $SERVICE_NAME --region=$GCP_REGION --limit=50 +``` + +The most common causes: +- MongoDB connection string is wrong or unreachable from Cloud Run +- `ASPNETCORE_ENVIRONMENT` is `Production` but `appsettings.Production.json` overrides something unexpectedly + +### Service URL returns 404 for all routes + +The service is running but no route matched. Confirm the app started correctly by checking logs for `Now listening on`. diff --git a/GCR/docker-compose.yml b/GCR/docker-compose.yml new file mode 100644 index 0000000..f628128 --- /dev/null +++ b/GCR/docker-compose.yml @@ -0,0 +1,30 @@ +# docker-compose.yml — Cloud Run Compose deployment +# +# Usage: +# gcloud run services replace docker-compose.yml --region=REGION +# +# Or via the deploy script: +# ./GCR/scripts/03-deploy.sh +# +# Environment variables are substituted from your shell or a .env file. +# Copy GCR/.env.example to GCR/.env and fill in your values before deploying. +# +# Cloud Run Compose reference: +# https://docs.cloud.google.com/run/docs/deploy-run-compose + +services: + app: + # IMAGE_URI is set by the deploy script after pushing to Artifact Registry. + # Format: REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/SERVICE_NAME:TAG + image: ${IMAGE_URI} + + environment: + # Non-sensitive config only — sensitive values (like MongoDB connection string) + # are injected via Secret Manager (--set-secrets) by the deploy script. + MongoDbName: ${MONGODB_DATABASE_NAME:-HtmxAppDb} + ASPNETCORE_ENVIRONMENT: Production + + # Cloud Run only honours the first port entry; the container must listen on + # the port Cloud Run advertises via the PORT env var (default 8080). + ports: + - target: 8080 diff --git a/GCR/entrypoint.sh b/GCR/entrypoint.sh new file mode 100755 index 0000000..7242030 --- /dev/null +++ b/GCR/entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# Translate Cloud Run's injected PORT env var into what ASP.NET Core reads. +# Cloud Run sets PORT (default 8080). ASP.NET Core reads ASPNETCORE_HTTP_PORTS. +export ASPNETCORE_HTTP_PORTS="${PORT:-8080}" +exec ./Htmx.ApiDemo "$@" diff --git a/GCR/run-all.ps1 b/GCR/run-all.ps1 new file mode 100644 index 0000000..946c333 --- /dev/null +++ b/GCR/run-all.ps1 @@ -0,0 +1,185 @@ +#Requires -Version 5.1 +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +param( + [switch]$Yes +) + +$RootDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$EnvFile = Join-Path $RootDir ".env" + +function Confirm-Run { + param( + [string]$Label, + [string]$ScriptPath + ) + + if ($Yes) { + Write-Host "[x] $Label not done yet. Running $ScriptPath (-Yes enabled)..." + & $ScriptPath + return + } + + $answer = Read-Host "[x] $Label not done yet. Run now? [y/N]" + if ($answer -match '^[Yy]$') { + & $ScriptPath + } +} + +function Get-EnvConfig { + if (-not (Test-Path $EnvFile)) { + throw "GCR/.env not found. Copy GCR/.env.example to GCR/.env first." + } + + $cfg = @{} + foreach ($line in Get-Content $EnvFile) { + if ($line -match '^\s*$' -or $line -match '^\s*#') { continue } + if ($line -match '^([^=]+)=(.*)$') { + $cfg[$Matches[1].Trim()] = $Matches[2].Trim() + } + } + + foreach ($key in @('GCP_PROJECT_ID', 'GCP_REGION', 'GCP_REPOSITORY', 'SERVICE_NAME')) { + if (-not $cfg[$key]) { + throw "$key is not set in GCR/.env" + } + } + + return $cfg +} + +function Test-GcloudInstalled { + return [bool](Get-Command gcloud -ErrorAction SilentlyContinue) +} + +function Test-Login { + param([hashtable]$Cfg) + + $activeAccount = (gcloud auth list --filter=status:ACTIVE --format="value(account)" 2>$null | Select-Object -First 1) + $currentProject = (gcloud config get-value project 2>$null) + $currentRegion = (gcloud config get-value run/region 2>$null) + + $dockerCfg = if ($env:DOCKER_CONFIG) { Join-Path $env:DOCKER_CONFIG "config.json" } else { Join-Path $HOME ".docker\config.json" } + $dockerOk = $false + if (Test-Path $dockerCfg) { + $dockerOk = (Select-String -Path $dockerCfg -Pattern "\"$($Cfg['GCP_REGION'])-docker.pkg.dev\"" -SimpleMatch -Quiet) + } + + return (-not [string]::IsNullOrWhiteSpace($activeAccount)) -and + ($currentProject.Trim() -eq $Cfg['GCP_PROJECT_ID']) -and + ($currentRegion.Trim() -eq $Cfg['GCP_REGION']) -and + $dockerOk +} + +function Test-ProjectSetup { + param([hashtable]$Cfg) + + $billingEnabled = (gcloud billing projects describe $Cfg['GCP_PROJECT_ID'] --format="value(billingEnabled)" 2>$null) + if ($billingEnabled -ne 'True') { return $false } + + try { + gcloud artifacts repositories describe $Cfg['GCP_REPOSITORY'] --location=$Cfg['GCP_REGION'] --project=$Cfg['GCP_PROJECT_ID'] 2>$null | Out-Null + } catch { + return $false + } + + foreach ($api in @('run.googleapis.com', 'artifactregistry.googleapis.com', 'secretmanager.googleapis.com', 'cloudresourcemanager.googleapis.com')) { + $enabled = gcloud services list --enabled --project=$Cfg['GCP_PROJECT_ID'] --format="value(config.name)" 2>$null | Select-String -Pattern "^$([regex]::Escape($api))$" + if (-not $enabled) { return $false } + } + + return $true +} + +function Test-SecretsSetup { + param([hashtable]$Cfg) + + try { + gcloud secrets describe mongodb-connection-string --project=$Cfg['GCP_PROJECT_ID'] 2>$null | Out-Null + } catch { + return $false + } + + $serviceAccount = "serviceAccount:$($Cfg['GCP_PROJECT_ID'])@appspot.gserviceaccount.com" + $binding = gcloud secrets get-iam-policy mongodb-connection-string ` + --project=$Cfg['GCP_PROJECT_ID'] ` + --flatten="bindings[].members" ` + --filter="bindings.role=roles/secretmanager.secretAccessor AND bindings.members=$serviceAccount" ` + --format="value(bindings.members)" 2>$null + + return ($binding -match [regex]::Escape($serviceAccount)) +} + +function Test-DeployDone { + param([hashtable]$Cfg) + + try { + gcloud run services describe $Cfg['SERVICE_NAME'] --region=$Cfg['GCP_REGION'] --project=$Cfg['GCP_PROJECT_ID'] 2>$null | Out-Null + return $true + } catch { + return $false + } +} + +function Write-Done { + param([string]$Text) + Write-Host "[v] $Text" +} + +Write-Host "================================================================" +Write-Host " Htmx deployment flow runner (Windows)" +Write-Host "================================================================" + +if (-not (Test-Path $EnvFile)) { + Write-Host "[x] Step 0: GCR/.env is missing" + Write-Host " Copy GCR/.env.example to GCR/.env and fill required values." + exit 1 +} + +Write-Done "Step 0: .env exists" +$cfg = Get-EnvConfig + +if (Test-GcloudInstalled) { + Write-Done "Step 1: gcloud installed" +} else { + Confirm-Run "Step 1: gcloud install" (Join-Path $RootDir "scripts\00-install-gcloud.ps1") +} + +if (Test-Login $cfg) { + Write-Done "Step 2: login + docker auth configured" +} else { + Confirm-Run "Step 2: login" (Join-Path $RootDir "scripts\01-login.ps1") +} + +if (Test-ProjectSetup $cfg) { + Write-Done "Step 3: project setup complete" +} else { + Confirm-Run "Step 3: project setup" (Join-Path $RootDir "scripts\02-setup-project.ps1") +} + +if (Test-SecretsSetup $cfg) { + Write-Done "Step 4: secrets created and access granted" +} else { + Confirm-Run "Step 4: secrets setup" (Join-Path $RootDir "scripts\03-create-secrets.ps1") +} + +if (Test-DeployDone $cfg) { + Write-Done "Step 5: service is already deployed" +} else { + Confirm-Run "Step 5: deploy" (Join-Path $RootDir "scripts\04-deploy.ps1") +} + +Write-Host "" +Write-Host "================================================================" +Write-Host " Final verification" +Write-Host "================================================================" + +if (Test-GcloudInstalled) { Write-Done "Step 1" } else { Write-Host "[x] Step 1" } +if (Test-Login $cfg) { Write-Done "Step 2" } else { Write-Host "[x] Step 2" } +if (Test-ProjectSetup $cfg) { Write-Done "Step 3" } else { Write-Host "[x] Step 3" } +if (Test-SecretsSetup $cfg) { Write-Done "Step 4" } else { Write-Host "[x] Step 4" } +if (Test-DeployDone $cfg) { Write-Done "Step 5" } else { Write-Host "[x] Step 5" } + +Write-Host "" +Write-Host "Tip: run .\GCR\run-all.ps1 -Yes to auto-run missing steps without prompts." diff --git a/GCR/run-all.sh b/GCR/run-all.sh new file mode 100644 index 0000000..3c39657 --- /dev/null +++ b/GCR/run-all.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "ERROR: This script is for Linux only." + echo "Windows users: run GCR/run-all.ps1" + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$SCRIPT_DIR/.env" +AUTO_YES="${1:-}" + +confirm_run() { + local label="$1" + local script_path="$2" + + if [[ "$AUTO_YES" == "--yes" ]]; then + echo "[x] $label not done yet. Running $script_path (--yes enabled)..." + bash "$script_path" + return + fi + + local answer + read -rp "[x] $label not done yet. Run now? [y/N]: " answer + if [[ "$answer" =~ ^[Yy]$ ]]; then + bash "$script_path" + fi +} + +load_env() { + # shellcheck disable=SC1090 + source "$ENV_FILE" + : "${GCP_PROJECT_ID:?GCP_PROJECT_ID is not set in .env}" + : "${GCP_REGION:?GCP_REGION is not set in .env}" + : "${GCP_REPOSITORY:?GCP_REPOSITORY is not set in .env}" + : "${SERVICE_NAME:?SERVICE_NAME is not set in .env}" +} + +check_env() { + [[ -f "$ENV_FILE" ]] +} + +check_gcloud_installed() { + command -v gcloud >/dev/null 2>&1 +} + +check_login() { + local active_account + local current_project + local current_region + local docker_cfg + + active_account="$(gcloud auth list --filter=status:ACTIVE --format="value(account)" 2>/dev/null | head -n1 || true)" + current_project="$(gcloud config get-value project 2>/dev/null || true)" + current_region="$(gcloud config get-value run/region 2>/dev/null || true)" + + docker_cfg="${DOCKER_CONFIG:-$HOME/.docker}/config.json" + [[ -n "$active_account" ]] && + [[ "$current_project" == "$GCP_PROJECT_ID" ]] && + [[ "$current_region" == "$GCP_REGION" ]] && + [[ -f "$docker_cfg" ]] && + grep -q "\"${GCP_REGION}-docker.pkg.dev\"" "$docker_cfg" +} + +check_project_setup() { + local billing_enabled + billing_enabled="$(gcloud billing projects describe "$GCP_PROJECT_ID" --format="value(billingEnabled)" 2>/dev/null || true)" + [[ "$billing_enabled" == "True" ]] || return 1 + + gcloud artifacts repositories describe "$GCP_REPOSITORY" \ + --location="$GCP_REGION" \ + --project="$GCP_PROJECT_ID" >/dev/null 2>&1 || return 1 + + local api + for api in run.googleapis.com artifactregistry.googleapis.com secretmanager.googleapis.com cloudresourcemanager.googleapis.com; do + gcloud services list --enabled --project="$GCP_PROJECT_ID" --format="value(config.name)" 2>/dev/null \ + | grep -Fxq "$api" || return 1 + done +} + +check_secrets_setup() { + local service_account + service_account="serviceAccount:${GCP_PROJECT_ID}@appspot.gserviceaccount.com" + + gcloud secrets describe mongodb-connection-string --project="$GCP_PROJECT_ID" >/dev/null 2>&1 || return 1 + + gcloud secrets get-iam-policy mongodb-connection-string \ + --project="$GCP_PROJECT_ID" \ + --flatten="bindings[].members" \ + --filter="bindings.role=roles/secretmanager.secretAccessor AND bindings.members=${service_account}" \ + --format="value(bindings.members)" 2>/dev/null \ + | grep -Fxq "$service_account" +} + +check_deploy_done() { + gcloud run services describe "$SERVICE_NAME" \ + --region="$GCP_REGION" \ + --project="$GCP_PROJECT_ID" >/dev/null 2>&1 +} + +print_done() { + echo "[v] $1" +} + +echo "================================================================" +echo " Htmx deployment flow runner (Linux)" +echo "================================================================" + +if check_env; then + print_done "Step 0: .env exists" +else + echo "[x] Step 0: GCR/.env is missing" + echo " Copy GCR/.env.example to GCR/.env and fill required values." + exit 1 +fi + +load_env + +if check_gcloud_installed; then + print_done "Step 1: gcloud installed" +else + confirm_run "Step 1: gcloud install" "$SCRIPT_DIR/scripts/00-install-gcloud.sh" +fi + +if check_login; then + print_done "Step 2: login + docker auth configured" +else + confirm_run "Step 2: login" "$SCRIPT_DIR/scripts/01-login.sh" +fi + +if check_project_setup; then + print_done "Step 3: project setup complete" +else + confirm_run "Step 3: project setup" "$SCRIPT_DIR/scripts/02-setup-project.sh" +fi + +if check_secrets_setup; then + print_done "Step 4: secrets created and access granted" +else + confirm_run "Step 4: secrets setup" "$SCRIPT_DIR/scripts/03-create-secrets.sh" +fi + +if check_deploy_done; then + print_done "Step 5: service is already deployed" +else + confirm_run "Step 5: deploy" "$SCRIPT_DIR/scripts/04-deploy.sh" +fi + +echo "" +echo "================================================================" +echo " Final verification" +echo "================================================================" + +if check_gcloud_installed; then print_done "Step 1"; else echo "[x] Step 1"; fi +if check_login; then print_done "Step 2"; else echo "[x] Step 2"; fi +if check_project_setup; then print_done "Step 3"; else echo "[x] Step 3"; fi +if check_secrets_setup; then print_done "Step 4"; else echo "[x] Step 4"; fi +if check_deploy_done; then print_done "Step 5"; else echo "[x] Step 5"; fi + +echo "" +echo "Tip: run 'bash GCR/run-all.sh --yes' to auto-run missing steps without prompts." diff --git a/GCR/scripts/00-install-gcloud.ps1 b/GCR/scripts/00-install-gcloud.ps1 new file mode 100644 index 0000000..5b779cd --- /dev/null +++ b/GCR/scripts/00-install-gcloud.ps1 @@ -0,0 +1,54 @@ +# ============================================================================= +# 00-install-gcloud.ps1 (Windows) +# Installs the Google Cloud CLI (gcloud) on Windows. +# Run this once in an elevated PowerShell prompt before doing anything else. +# +# Linux users: run GCR/scripts/00-install-gcloud.sh instead. +# ============================================================================= +#Requires -Version 5.1 +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Write-Host ">>> Installing Google Cloud CLI on Windows..." +Write-Host "" + +# ── Try winget first (available on Windows 10 1709+ / Windows 11) ──────────── +if (Get-Command winget -ErrorAction SilentlyContinue) { + Write-Host ">>> winget found — installing Google Cloud SDK via winget..." + winget install --id Google.CloudSDK --accept-source-agreements --accept-package-agreements + Write-Host "" + Write-Host ">>> gcloud installed via winget." + Write-Host " Close and reopen PowerShell so PATH changes take effect." +} +# ── Fallback: download the official Windows installer ──────────────────────── +else { + Write-Host ">>> winget not available — downloading the official installer..." + Write-Host "" + + $InstallerUrl = "https://dl.google.com/dl/cloudsdk/channels/rapid/GoogleCloudSDKInstaller.exe" + $InstallerPath = "$env:TEMP\GoogleCloudSDKInstaller.exe" + + Write-Host " Downloading from: $InstallerUrl" + Invoke-WebRequest -Uri $InstallerUrl -OutFile $InstallerPath -UseBasicParsing + + Write-Host " Launching installer..." + Write-Host " Follow the on-screen prompts. Make sure 'Add gcloud to PATH' is checked." + Write-Host "" + Start-Process -FilePath $InstallerPath -Wait + Remove-Item $InstallerPath -Force +} + +Write-Host "" +Write-Host ">>> Verifying installation..." +try { + $version = & gcloud version 2>&1 | Select-Object -First 1 + Write-Host " $version" + Write-Host "" + Write-Host ">>> gcloud is installed." +} catch { + Write-Host " gcloud not found on PATH yet." + Write-Host " Close and reopen PowerShell, then run: gcloud version" +} + +Write-Host "" +Write-Host ">>> Next step: run GCR\scripts\01-login.ps1" diff --git a/GCR/scripts/00-install-gcloud.sh b/GCR/scripts/00-install-gcloud.sh new file mode 100755 index 0000000..d59357f --- /dev/null +++ b/GCR/scripts/00-install-gcloud.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# ============================================================================= +# 00-install-gcloud.sh (Linux) +# Installs the Google Cloud CLI (gcloud) on Debian/Ubuntu. +# Run this once on a new machine before doing anything else. +# +# Windows users: run GCR/scripts/00-install-gcloud.ps1 in PowerShell instead. +# ============================================================================= +set -euo pipefail + +OS="$(uname -s)" +if [[ "$OS" != "Linux" ]]; then + echo "ERROR: This script is for Linux only." + echo "Windows users: run GCR/scripts/00-install-gcloud.ps1" + exit 1 +fi + +echo ">>> Installing Google Cloud CLI on Linux (Debian/Ubuntu)..." + +apt_update_with_retry() { + local attempts=5 + local i + + for ((i=1; i<=attempts; i++)); do + if sudo apt-get clean && sudo rm -rf /var/lib/apt/lists/* && sudo apt-get update; then + return 0 + fi + + echo " apt-get update failed (attempt ${i}/${attempts})." + if (( i < attempts )); then + echo " Retrying apt metadata refresh..." + fi + done + + echo "ERROR: apt-get update failed after ${attempts} attempts." + echo "This is often a temporary mirror sync issue. Please try again in a few minutes." + exit 1 +} + +apt_install_with_retry() { + local attempts=3 + local i + + for ((i=1; i<=attempts; i++)); do + if sudo apt-get install -y --no-install-recommends "$@"; then + return 0 + fi + + echo " apt-get install failed (attempt ${i}/${attempts}) for: $*" + if (( i < attempts )); then + echo " Retrying package install..." + fi + done + + echo "ERROR: apt-get install failed after ${attempts} attempts for: $*" + exit 1 +} + +# ── Install dependencies ─────────────────────────────────────────────────── +apt_update_with_retry +apt_install_with_retry \ + apt-transport-https \ + ca-certificates \ + curl \ + gnupg + +# ── Import the Google Cloud signing key ─────────────────────────────────── +# Key is downloaded to a file rather than piped straight into gpg so it can +# be inspected or cached by CI systems if needed. +curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \ + -o /tmp/cloud.google.gpg +sudo gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg /tmp/cloud.google.gpg +rm /tmp/cloud.google.gpg + +# ── Add the apt repository ──────────────────────────────────────────────── +echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] \ +https://packages.cloud.google.com/apt cloud-sdk main" \ + | sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list + +# ── Install gcloud ──────────────────────────────────────────────────────── +apt_update_with_retry +apt_install_with_retry google-cloud-cli + +echo "" +echo ">>> gcloud installed successfully." +gcloud version +echo "" +echo ">>> Next step: run GCR/scripts/01-login.sh" diff --git a/GCR/scripts/01-login.ps1 b/GCR/scripts/01-login.ps1 new file mode 100644 index 0000000..1d4dac8 --- /dev/null +++ b/GCR/scripts/01-login.ps1 @@ -0,0 +1,62 @@ +# ============================================================================= +# 01-login.ps1 (Windows) +# Authenticates your local machine to Google Cloud and configures Docker +# to push images to Artifact Registry. +# +# Run this once per machine (or whenever your credentials expire). +# Linux users: run GCR/scripts/01-login.sh instead. +# ============================================================================= +#Requires -Version 5.1 +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$EnvFile = Join-Path $ScriptDir "..\\.env" + +# ── Load .env ───────────────────────────────────────────────────────────────── +if (-not (Test-Path $EnvFile)) { + Write-Error "ERROR: $EnvFile not found.`nCopy GCR\.env.example to GCR\.env and fill in your values first." + exit 1 +} + +$config = @{} +foreach ($line in Get-Content $EnvFile) { + # Skip blank lines and comments + if ($line -match '^\s*$' -or $line -match '^\s*#') { continue } + if ($line -match '^([^=]+)=(.*)$') { + $config[$Matches[1].Trim()] = $Matches[2].Trim() + } +} + +$GCP_PROJECT_ID = $config['GCP_PROJECT_ID'] ?? '' +$GCP_REGION = $config['GCP_REGION'] ?? '' + +if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID is not set in .env"; exit 1 } +if (-not $GCP_REGION) { Write-Error "GCP_REGION is not set in .env"; exit 1 } + +# ── Step 1: Authenticate user account ──────────────────────────────────────── +Write-Host ">>> Logging in to Google Cloud..." +Write-Host " A browser window will open. Sign in with the Google account" +Write-Host " that has access to project: $GCP_PROJECT_ID" +Write-Host "" +gcloud auth login + +# ── Step 2: Set default project ────────────────────────────────────────────── +Write-Host "" +Write-Host ">>> Setting default project to: $GCP_PROJECT_ID" +gcloud config set project $GCP_PROJECT_ID + +# ── Step 3: Set default region ─────────────────────────────────────────────── +Write-Host ">>> Setting default region to: $GCP_REGION" +gcloud config set run/region $GCP_REGION + +# ── Step 4: Configure Docker to authenticate against Artifact Registry ──────── +Write-Host "" +Write-Host ">>> Configuring Docker to authenticate with Artifact Registry..." +gcloud auth configure-docker "$GCP_REGION-docker.pkg.dev" --quiet + +Write-Host "" +Write-Host ">>> Login complete. You are now authenticated as:" +gcloud auth list --filter=status:ACTIVE --format="value(account)" +Write-Host "" +Write-Host ">>> Next step: run GCR\scripts\02-setup-project.ps1" diff --git a/GCR/scripts/01-login.sh b/GCR/scripts/01-login.sh new file mode 100755 index 0000000..2fc1c4d --- /dev/null +++ b/GCR/scripts/01-login.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# ============================================================================= +# 01-login.sh (Linux) +# Authenticates your local machine to Google Cloud and configures Docker +# to push images to Artifact Registry. +# +# Run this once per machine (or whenever your credentials expire). +# Windows users: run GCR/scripts/01-login.ps1 in PowerShell instead. +# ============================================================================= +set -euo pipefail + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "ERROR: This script is for Linux only." + echo "Windows users: run GCR/scripts/01-login.ps1" + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Load .env ───────────────────────────────────────────────────────────────── +ENV_FILE="$SCRIPT_DIR/../.env" +if [[ ! -f "$ENV_FILE" ]]; then + echo "ERROR: $ENV_FILE not found." + echo "Copy GCR/.env.example to GCR/.env and fill in your values first." + exit 1 +fi +# shellcheck disable=SC1090 +source "$ENV_FILE" + +: "${GCP_PROJECT_ID:?GCP_PROJECT_ID is not set in .env}" +: "${GCP_REGION:?GCP_REGION is not set in .env}" + +# ── Step 1: Authenticate user account ──────────────────────────────────────── +echo ">>> Logging in to Google Cloud..." +echo " A browser window will open. Sign in with the Google account that has" +echo " access to project: $GCP_PROJECT_ID" +echo "" +gcloud auth login + +# ── Step 2: Set default project ────────────────────────────────────────────── +echo "" +echo ">>> Setting default project to: $GCP_PROJECT_ID" +gcloud config set project "$GCP_PROJECT_ID" + +# ── Step 3: Set default region ─────────────────────────────────────────────── +echo ">>> Setting default region to: $GCP_REGION" +gcloud config set run/region "$GCP_REGION" + +# ── Step 4: Configure Docker to authenticate against Artifact Registry ──────── +echo "" +echo ">>> Configuring Docker to authenticate with Artifact Registry..." +gcloud auth configure-docker "${GCP_REGION}-docker.pkg.dev" --quiet + +echo "" +echo ">>> Login complete. You are now authenticated as:" +gcloud auth list --filter=status:ACTIVE --format="value(account)" +echo "" +echo ">>> Next step: run GCR/scripts/02-setup-project.sh" diff --git a/GCR/scripts/02-setup-project.ps1 b/GCR/scripts/02-setup-project.ps1 new file mode 100644 index 0000000..97c8c2d --- /dev/null +++ b/GCR/scripts/02-setup-project.ps1 @@ -0,0 +1,126 @@ +# ============================================================================= +# 02-setup-project.ps1 (Windows) +# One-time GCP project setup: +# - Links a billing account to the project +# - Enables required APIs (Cloud Run, Artifact Registry, Secret Manager) +# - Creates an Artifact Registry Docker repository +# - Grants the current user the minimum required IAM roles +# +# Safe to re-run — most operations are idempotent. +# Linux users: run GCR/scripts/02-setup-project.sh instead. +# ============================================================================= +#Requires -Version 5.1 +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$EnvFile = Join-Path $ScriptDir "..\\.env" + +# ── Load .env ───────────────────────────────────────────────────────────────── +if (-not (Test-Path $EnvFile)) { + Write-Error "ERROR: $EnvFile not found.`nCopy GCR\.env.example to GCR\.env and fill in your values first." + exit 1 +} + +$config = @{} +foreach ($line in Get-Content $EnvFile) { + if ($line -match '^\s*$' -or $line -match '^\s*#') { continue } + if ($line -match '^([^=]+)=(.*)$') { + $config[$Matches[1].Trim()] = $Matches[2].Trim() + } +} + +$GCP_PROJECT_ID = $config['GCP_PROJECT_ID'] ?? '' +$GCP_REGION = $config['GCP_REGION'] ?? '' +$GCP_REPOSITORY = $config['GCP_REPOSITORY'] ?? '' + +if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID is not set in .env"; exit 1 } +if (-not $GCP_REGION) { Write-Error "GCP_REGION is not set in .env"; exit 1 } +if (-not $GCP_REPOSITORY) { Write-Error "GCP_REPOSITORY is not set in .env"; exit 1 } + +Write-Host ">>> Active project: $GCP_PROJECT_ID" +Write-Host ">>> Region: $GCP_REGION" +Write-Host ">>> AR repository: $GCP_REPOSITORY" +Write-Host "" + +# ── Step 1: Link billing account ────────────────────────────────────────────── +Write-Host ">>> Checking billing status..." +$billingOutput = gcloud billing projects describe $GCP_PROJECT_ID --format="value(billingEnabled)" 2>$null +$billingEnabled = ($billingOutput -eq "True") + +if ($billingEnabled) { + Write-Host " Billing is already enabled — skipping." +} else { + Write-Host "" + Write-Host " Billing is NOT enabled on this project." + Write-Host " Listing available billing accounts..." + Write-Host "" + gcloud billing accounts list --format="table(name,displayName,open)" + Write-Host "" + $BILLING_ACCOUNT_ID = Read-Host " Enter the BILLING_ACCOUNT_ID from the list above (format: XXXXXX-XXXXXX-XXXXXX)" + gcloud billing projects link $GCP_PROJECT_ID --billing-account=$BILLING_ACCOUNT_ID + Write-Host " Billing linked." +} + +# ── Step 2: Enable required APIs ───────────────────────────────────────────── +Write-Host "" +Write-Host ">>> Enabling required Google Cloud APIs (this may take a minute)..." +gcloud services enable ` + run.googleapis.com ` + artifactregistry.googleapis.com ` + secretmanager.googleapis.com ` + cloudresourcemanager.googleapis.com ` + --project=$GCP_PROJECT_ID +Write-Host " APIs enabled." + +# ── Step 3: Create Artifact Registry Docker repository ─────────────────────── +Write-Host "" +Write-Host ">>> Creating Artifact Registry repository: $GCP_REPOSITORY ..." +$repoExists = $false +try { + gcloud artifacts repositories describe $GCP_REPOSITORY ` + --location=$GCP_REGION ` + --project=$GCP_PROJECT_ID 2>$null | Out-Null + $repoExists = $true +} catch { } + +if ($repoExists) { + Write-Host " Repository already exists — skipping." +} else { + gcloud artifacts repositories create $GCP_REPOSITORY ` + --repository-format=docker ` + --location=$GCP_REGION ` + --description="Container images for Htmx app" ` + --project=$GCP_PROJECT_ID + Write-Host " Repository created." +} + +# ── Step 4: Grant current user the minimum required IAM roles ───────────────── +$CURRENT_USER = (gcloud config get-value account).Trim() +Write-Host "" +Write-Host ">>> Granting IAM roles to $CURRENT_USER ..." + +foreach ($role in @( + "roles/run.developer", + "roles/artifactregistry.writer", + "roles/iam.serviceAccountUser", + "roles/secretmanager.admin", + "roles/secretmanager.secretAccessor", + "roles/secretmanager.secretVersionAdder" +)) { + Write-Host " Adding role: $role" + gcloud projects add-iam-policy-binding $GCP_PROJECT_ID ` + --member="user:$CURRENT_USER" ` + --role=$role ` + --quiet +} + +Write-Host "" +Write-Host ">>> Project setup complete." +Write-Host "" +Write-Host ">>> Summary:" +Write-Host " Project ID: $GCP_PROJECT_ID" +Write-Host " Region: $GCP_REGION" +Write-Host " Artifact Registry: $GCP_REGION-docker.pkg.dev/$GCP_PROJECT_ID/$GCP_REPOSITORY" +Write-Host "" +Write-Host ">>> Next step: run GCR\scripts\03-create-secrets.ps1" diff --git a/GCR/scripts/02-setup-project.sh b/GCR/scripts/02-setup-project.sh new file mode 100755 index 0000000..5a27d5f --- /dev/null +++ b/GCR/scripts/02-setup-project.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# ============================================================================= +# 02-setup-project.sh (Linux) +# One-time GCP project setup: +# - Links a billing account to the project +# - Enables required APIs (Cloud Run, Artifact Registry, Secret Manager) +# - Creates an Artifact Registry Docker repository +# - Grants the current user the minimum required IAM roles +# +# Safe to re-run — most operations are idempotent. +# Windows users: run GCR/scripts/02-setup-project.ps1 in PowerShell instead. +# ============================================================================= +set -euo pipefail + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "ERROR: This script is for Linux only." + echo "Windows users: run GCR/scripts/02-setup-project.ps1" + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Load .env ───────────────────────────────────────────────────────────────── +ENV_FILE="$SCRIPT_DIR/../.env" +if [[ ! -f "$ENV_FILE" ]]; then + echo "ERROR: $ENV_FILE not found." + echo "Copy GCR/.env.example to GCR/.env and fill in your values first." + exit 1 +fi +# shellcheck disable=SC1090 +source "$ENV_FILE" + +: "${GCP_PROJECT_ID:?GCP_PROJECT_ID is not set in .env}" +: "${GCP_REGION:?GCP_REGION is not set in .env}" +: "${GCP_REPOSITORY:?GCP_REPOSITORY is not set in .env}" + +# ── Confirm active project ──────────────────────────────────────────────────── +echo ">>> Active project: $GCP_PROJECT_ID" +echo ">>> Region: $GCP_REGION" +echo ">>> AR repository: $GCP_REPOSITORY" +echo "" + +# ── Step 1: Link billing account ────────────────────────────────────────────── +echo ">>> Checking billing status..." +BILLING_ENABLED=$(gcloud billing projects describe "$GCP_PROJECT_ID" \ + --format="value(billingEnabled)" 2>/dev/null || echo "false") + +if [[ "$BILLING_ENABLED" == "True" ]]; then + echo " Billing is already enabled — skipping." +else + echo "" + echo " Billing is NOT enabled on this project." + echo " Listing available billing accounts..." + echo "" + gcloud billing accounts list --format="table(name,displayName,open)" + echo "" + read -rp " Enter the BILLING_ACCOUNT_ID from the list above (format: XXXXXX-XXXXXX-XXXXXX): " BILLING_ACCOUNT_ID + gcloud billing projects link "$GCP_PROJECT_ID" \ + --billing-account="$BILLING_ACCOUNT_ID" + echo " Billing linked." +fi + +# ── Step 2: Enable required APIs ───────────────────────────────────────────── +echo "" +echo ">>> Enabling required Google Cloud APIs (this may take a minute)..." +gcloud services enable \ + run.googleapis.com \ + artifactregistry.googleapis.com \ + secretmanager.googleapis.com \ + cloudresourcemanager.googleapis.com \ + --project="$GCP_PROJECT_ID" +echo " APIs enabled." + +# ── Step 3: Create Artifact Registry Docker repository ─────────────────────── +echo "" +echo ">>> Creating Artifact Registry repository: $GCP_REPOSITORY ..." +if gcloud artifacts repositories describe "$GCP_REPOSITORY" \ + --location="$GCP_REGION" \ + --project="$GCP_PROJECT_ID" &>/dev/null; then + echo " Repository already exists — skipping." +else + gcloud artifacts repositories create "$GCP_REPOSITORY" \ + --repository-format=docker \ + --location="$GCP_REGION" \ + --description="Container images for Htmx app" \ + --project="$GCP_PROJECT_ID" + echo " Repository created." +fi + +# ── Step 4: Grant current user the minimum required IAM roles ───────────────── +CURRENT_USER="$(gcloud config get-value account)" +echo "" +echo ">>> Granting IAM roles to $CURRENT_USER ..." + +for ROLE in \ + "roles/run.developer" \ + "roles/artifactregistry.writer" \ + "roles/iam.serviceAccountUser" \ + "roles/secretmanager.admin" \ + "roles/secretmanager.secretAccessor" \ + "roles/secretmanager.secretVersionAdder"; do + echo " Adding role: $ROLE" + gcloud projects add-iam-policy-binding "$GCP_PROJECT_ID" \ + --member="user:$CURRENT_USER" \ + --role="$ROLE" \ + --quiet +done + +echo "" +echo ">>> Project setup complete." +echo "" +echo ">>> Summary:" +echo " Project ID: $GCP_PROJECT_ID" +echo " Region: $GCP_REGION" +echo " Artifact Registry: ${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${GCP_REPOSITORY}" +echo "" +echo ">>> Next step: run GCR/scripts/03-create-secrets.sh" diff --git a/GCR/scripts/03-create-secrets.ps1 b/GCR/scripts/03-create-secrets.ps1 new file mode 100644 index 0000000..70f8271 --- /dev/null +++ b/GCR/scripts/03-create-secrets.ps1 @@ -0,0 +1,122 @@ +# ============================================================================= +# 03-create-secrets.ps1 (Windows) +# Creates and configures secrets in Google Cloud Secret Manager. +# +# Run this after 02-setup-project.ps1 to set up sensitive configuration +# values (e.g., MongoDB connection string). +# +# Linux users: run GCR/scripts/03-create-secrets.sh instead. +# ============================================================================= +#Requires -Version 5.1 +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$EnvFile = Join-Path $ScriptDir "..\\.env" + +# ── Load .env ───────────────────────────────────────────────────────────────── +if (-not (Test-Path $EnvFile)) { + Write-Error "ERROR: $EnvFile not found.`nCopy GCR\.env.example to GCR\.env and fill in your values first." + exit 1 +} + +$config = @{} +foreach ($line in Get-Content $EnvFile) { + if ($line -match '^\s*$' -or $line -match '^\s*#') { continue } + if ($line -match '^([^=]+)=(.*)$') { + $config[$Matches[1].Trim()] = $Matches[2].Trim() + } +} + +$GCP_PROJECT_ID = $config['GCP_PROJECT_ID'] ?? '' +if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID is not set in .env"; exit 1 } + +Write-Host "================================================================" +Write-Host " Google Cloud Secret Manager setup" +Write-Host "================================================================" +Write-Host " Project: $GCP_PROJECT_ID" +Write-Host "" + +# ── Helper function to create or update a secret ────────────────────────────── +function New-OrUpdateSecret { + param( + [string]$SecretName, + [string]$SecretPrompt + ) + + Write-Host ">>> Setting up secret: $SecretName" + Write-Host " $SecretPrompt" + + # Read secret without echo + $SecretValue = Read-Host " Enter value (will not be echoed)" -AsSecureString + $PlainValue = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto( + [System.Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUni($SecretValue) + ) + + # Write to temp file without trailing newline to avoid contaminating the secret + $TempFile = [System.IO.Path]::GetTempFileName() + try { + [System.IO.File]::WriteAllText($TempFile, $PlainValue, [System.Text.Encoding]::UTF8) + + $secretExists = $false + try { + gcloud secrets describe $SecretName --project=$GCP_PROJECT_ID 2>$null | Out-Null + $secretExists = $true + } catch { } + + if ($secretExists) { + Write-Host " Secret already exists — creating new version..." + gcloud secrets versions add $SecretName ` + --data-file=$TempFile ` + --project=$GCP_PROJECT_ID + } else { + Write-Host " Creating new secret..." + gcloud secrets create $SecretName ` + --data-file=$TempFile ` + --replication-policy="automatic" ` + --project=$GCP_PROJECT_ID + } + } finally { + Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + } + + Write-Host " ✓ Secret '$SecretName' ready." + Write-Host "" +} + +# ── Step 1: Create MongoDB connection string secret ────────────────────────── +New-OrUpdateSecret ` + "mongodb-connection-string" ` + "MongoDB Atlas or self-hosted connection URI (e.g., mongodb+srv://user:pass@cluster.mongodb.net)" + +# ── Step 2: Grant Cloud Run service account access to secrets ───────────────── +Write-Host ">>> Granting Cloud Run service account access to secrets..." +Write-Host "" + +# Get the default Cloud Run service account for this project +$SERVICE_ACCOUNT = "$GCP_PROJECT_ID@appspot.gserviceaccount.com" + +foreach ($SECRET_NAME in @("mongodb-connection-string")) { + Write-Host " Granting Secret Accessor role for '$SECRET_NAME' to $SERVICE_ACCOUNT" + gcloud secrets add-iam-policy-binding $SECRET_NAME ` + --member="serviceAccount:$SERVICE_ACCOUNT" ` + --role="roles/secretmanager.secretAccessor" ` + --project=$GCP_PROJECT_ID ` + --quiet +} + +Write-Host "" +Write-Host "================================================================" +Write-Host " Secret Manager setup complete!" +Write-Host "================================================================" +Write-Host "" +Write-Host ">>> Summary:" +Write-Host " Secrets created:" +Write-Host " • mongodb-connection-string" +Write-Host "" +Write-Host " Service account granted access:" +Write-Host " • $SERVICE_ACCOUNT" +Write-Host "" +Write-Host ">>> Next step: run GCR\scripts\04-deploy.ps1" +Write-Host " (The deploy script will automatically inject secrets into" +Write-Host " the running container.)" diff --git a/GCR/scripts/03-create-secrets.sh b/GCR/scripts/03-create-secrets.sh new file mode 100644 index 0000000..caa422e --- /dev/null +++ b/GCR/scripts/03-create-secrets.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# ============================================================================= +# 03-create-secrets.sh (Linux) +# Creates and configures secrets in Google Cloud Secret Manager. +# +# Run this after 02-setup-project.sh to set up sensitive configuration +# values (e.g., MongoDB connection string). +# +# Windows users: run GCR/scripts/03-create-secrets.ps1 in PowerShell instead. +# ============================================================================= +set -euo pipefail + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "ERROR: This script is for Linux only." + echo "Windows users: run GCR/scripts/03-create-secrets.ps1" + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Load .env ───────────────────────────────────────────────────────────────── +ENV_FILE="$SCRIPT_DIR/../.env" +if [[ ! -f "$ENV_FILE" ]]; then + echo "ERROR: $ENV_FILE not found." + echo "Copy GCR/.env.example to GCR/.env and fill in your values first." + exit 1 +fi +# shellcheck disable=SC1090 +source "$ENV_FILE" + +: "${GCP_PROJECT_ID:?GCP_PROJECT_ID is not set in .env}" + +echo "================================================================" +echo " Google Cloud Secret Manager setup" +echo "================================================================" +echo " Project: $GCP_PROJECT_ID" +echo "" + +# ── Helper function to create or update a secret ────────────────────────────── +create_or_update_secret() { + local SECRET_NAME="$1" + local SECRET_PROMPT="$2" + + echo ">>> Setting up secret: $SECRET_NAME" + echo " $SECRET_PROMPT" + read -rsp " Enter value (will not be echoed): " SECRET_VALUE + echo "" + + if gcloud secrets describe "$SECRET_NAME" --project="$GCP_PROJECT_ID" &>/dev/null; then + echo " Secret already exists — creating new version..." + printf '%s' "$SECRET_VALUE" | gcloud secrets versions add "$SECRET_NAME" \ + --data-file=- \ + --project="$GCP_PROJECT_ID" + else + echo " Creating new secret..." + printf '%s' "$SECRET_VALUE" | gcloud secrets create "$SECRET_NAME" \ + --data-file=- \ + --replication-policy="automatic" \ + --project="$GCP_PROJECT_ID" + fi + + echo " ✓ Secret '$SECRET_NAME' ready." + echo "" +} + +# ── Step 1: Create MongoDB connection string secret ────────────────────────── +create_or_update_secret \ + "mongodb-connection-string" \ + "MongoDB Atlas or self-hosted connection URI (e.g., mongodb+srv://user:pass@cluster.mongodb.net)" + +# ── Step 2: Grant Cloud Run service account access to secrets ───────────────── +echo ">>> Granting Cloud Run service account access to secrets..." +echo "" + +# Get the default Cloud Run service account for this project +SERVICE_ACCOUNT="$GCP_PROJECT_ID@appspot.gserviceaccount.com" + +for SECRET_NAME in mongodb-connection-string; do + echo " Granting Secret Accessor role for '$SECRET_NAME' to $SERVICE_ACCOUNT" + gcloud secrets add-iam-policy-binding "$SECRET_NAME" \ + --member="serviceAccount:$SERVICE_ACCOUNT" \ + --role="roles/secretmanager.secretAccessor" \ + --project="$GCP_PROJECT_ID" \ + --quiet +done + +echo "" +echo "================================================================" +echo " Secret Manager setup complete!" +echo "================================================================" +echo "" +echo ">>> Summary:" +echo " Secrets created:" +echo " • mongodb-connection-string" +echo "" +echo " Service account granted access:" +echo " • $SERVICE_ACCOUNT" +echo "" +echo ">>> Next step: run GCR/scripts/04-deploy.sh" +echo " (The deploy script will automatically inject secrets into" +echo " the running container.)" diff --git a/GCR/scripts/04-deploy.ps1 b/GCR/scripts/04-deploy.ps1 new file mode 100644 index 0000000..49c2f0a --- /dev/null +++ b/GCR/scripts/04-deploy.ps1 @@ -0,0 +1,194 @@ +# ============================================================================= +# 04-deploy.ps1 (Windows) +# Builds the Docker image, pushes it to Artifact Registry, and deploys it +# to Cloud Run — all in one command. +# +# Usage: +# .\GCR\scripts\04-deploy.ps1 # deploy with tag = git short SHA +# .\GCR\scripts\04-deploy.ps1 -Tag my-tag # deploy with a custom tag +# +# Prerequisites: +# 1. GCR\.env exists and is filled in (copy from GCR\.env.example) +# 2. 01-login.ps1 has been run (gcloud auth + Docker configured) +# 3. 02-setup-project.ps1 has been run (APIs enabled, repo created) +# 4. 03-create-secrets.ps1 has been run (MongoDB secret created) +# 5. Docker Desktop is running +# +# Linux users: run GCR/scripts/04-deploy.sh instead. +# ============================================================================= +#Requires -Version 5.1 +[CmdletBinding()] +param( + [string]$Tag = "" +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = (Resolve-Path (Join-Path $ScriptDir "..\..")).Path +$EnvFile = Join-Path $ScriptDir "..\\.env" + +# ── Load .env ───────────────────────────────────────────────────────────────── +if (-not (Test-Path $EnvFile)) { + Write-Error "ERROR: $EnvFile not found.`nCopy GCR\.env.example to GCR\.env and fill in your values first." + exit 1 +} + +$config = @{} +foreach ($line in Get-Content $EnvFile) { + if ($line -match '^\s*$' -or $line -match '^\s*#') { continue } + if ($line -match '^([^=]+)=(.*)$') { + $config[$Matches[1].Trim()] = $Matches[2].Trim() + } +} + +$GCP_PROJECT_ID = $config['GCP_PROJECT_ID'] ?? '' +$GCP_REGION = $config['GCP_REGION'] ?? '' +$GCP_REPOSITORY = $config['GCP_REPOSITORY'] ?? '' +$SERVICE_NAME = $config['SERVICE_NAME'] ?? '' +$MONGODB_DATABASE_NAME = $config['MONGODB_DATABASE_NAME'] ?? 'HtmxAppDb' + +# Note: MONGODB_CONNECTION_STRING is stored in Secret Manager (mongodb-connection-string) + +if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID is not set in .env"; exit 1 } +if (-not $GCP_REGION) { Write-Error "GCP_REGION is not set in .env"; exit 1 } +if (-not $GCP_REPOSITORY) { Write-Error "GCP_REPOSITORY is not set in .env"; exit 1 } +if (-not $SERVICE_NAME) { Write-Error "SERVICE_NAME is not set in .env"; exit 1 } + +# Note: MONGODB_CONNECTION_STRING is stored in Secret Manager + +function Test-SecretsReady { + $serviceAccount = "serviceAccount:$GCP_PROJECT_ID@appspot.gserviceaccount.com" + + try { + gcloud secrets describe mongodb-connection-string --project=$GCP_PROJECT_ID 2>$null | Out-Null + } catch { + return $false + } + + $binding = gcloud secrets get-iam-policy mongodb-connection-string ` + --project=$GCP_PROJECT_ID ` + --flatten="bindings[].members" ` + --filter="bindings.role=roles/secretmanager.secretAccessor AND bindings.members=$serviceAccount" ` + --format="value(bindings.members)" 2>$null + + return ($binding -match [regex]::Escape($serviceAccount)) +} + +if (-not (Test-SecretsReady)) { + Write-Host "" + Write-Host ">>> Required secrets are not fully configured yet." + $runSecretSetup = Read-Host " Run GCR\scripts\03-create-secrets.ps1 now? [y/N]" + if ($runSecretSetup -match '^[Yy]$') { + & (Join-Path $ScriptDir "03-create-secrets.ps1") + } else { + Write-Host "" + Write-Error "Deployment requires secret setup first. Run: .\GCR\scripts\03-create-secrets.ps1" + exit 1 + } + + if (-not (Test-SecretsReady)) { + Write-Host "" + Write-Error "Secret setup check still failing after running 03-create-secrets.ps1." + exit 1 + } +} + +# ── Determine image tag ──────────────────────────────────────────────────────── +if (-not $Tag) { + # Default to git short SHA if inside a git repo; otherwise use timestamp + try { + $Tag = (git -C $RepoRoot rev-parse --short HEAD 2>$null).Trim() + } catch { } + if (-not $Tag) { + $Tag = (Get-Date -Format "yyyyMMddHHmmss") + } +} + +$REGISTRY = "$GCP_REGION-docker.pkg.dev" +$IMAGE_URI = "$REGISTRY/$GCP_PROJECT_ID/$GCP_REPOSITORY/${SERVICE_NAME}:$Tag" + +Write-Host "================================================================" +Write-Host " Htmx -> Cloud Run deployment" +Write-Host "================================================================" +Write-Host " Project: $GCP_PROJECT_ID" +Write-Host " Region: $GCP_REGION" +Write-Host " Service: $SERVICE_NAME" +Write-Host " Image: $IMAGE_URI" +Write-Host "================================================================" +Write-Host "" + +# ── Step 1: Ensure package-lock.json exists (required for `npm ci`) ─────────── +$LockFile = Join-Path $RepoRoot "Htmx.ApiDemo\package-lock.json" +if (-not (Test-Path $LockFile)) { + Write-Host ">>> package-lock.json not found. Generating it now..." + Write-Host " (This requires node + npm to be installed locally)" + Push-Location (Join-Path $RepoRoot "Htmx.ApiDemo") + npm install --package-lock-only + Pop-Location + Write-Host " package-lock.json generated. Commit it to the repository." + Write-Host "" +} + +# ── Step 2: Build the Docker image ──────────────────────────────────────────── +Write-Host ">>> Building Docker image..." +Write-Host " Context: $RepoRoot" +Write-Host " Dockerfile: GCR\Dockerfile" +Write-Host "" + +# Build from repo root so COPY instructions can reach both project directories. +# Docker on Windows accepts forward slashes in --file. +$DockerFile = Join-Path $RepoRoot "GCR\Dockerfile" +docker build --file $DockerFile --tag $IMAGE_URI $RepoRoot + +Write-Host "" +Write-Host ">>> Image built: $IMAGE_URI" + +# ── Step 3: Push image to Artifact Registry ─────────────────────────────────── +Write-Host "" +Write-Host ">>> Pushing image to Artifact Registry..." +docker push $IMAGE_URI +Write-Host ">>> Push complete." + +# ── Step 4: Deploy to Cloud Run via docker-compose.yml ─────────────────────── +Write-Host "" +Write-Host ">>> Deploying to Cloud Run..." + +# Set env vars consumed by docker-compose.yml variable substitution +$env:IMAGE_URI = $IMAGE_URI +$env:MONGODB_DATABASE_NAME = $MONGODB_DATABASE_NAME + +$ComposeFile = Join-Path $RepoRoot "GCR\docker-compose.yml" +gcloud run services replace $ComposeFile ` + --region=$GCP_REGION ` + --project=$GCP_PROJECT_ID + +# ── Step 4b: Inject MongoDB connection string from Secret Manager ──────────── +Write-Host "" +Write-Host ">>> Injecting MongoDB connection string from Secret Manager..." +gcloud run services update $SERVICE_NAME ` + --region=$GCP_REGION ` + --project=$GCP_PROJECT_ID ` + --set-secrets="ConnectionStrings__DefaultConnection=mongodb-connection-string:latest" + +# ── Step 5: Make the service publicly accessible ────────────────────────────── +# Remove this block if you want the service to require authentication. +Write-Host "" +Write-Host ">>> Allowing public (unauthenticated) access to the service..." +gcloud run services add-iam-policy-binding $SERVICE_NAME ` + --region=$GCP_REGION ` + --project=$GCP_PROJECT_ID ` + --member="allUsers" ` + --role="roles/run.invoker" + +# ── Print service URL ───────────────────────────────────────────────────────── +Write-Host "" +$SERVICE_URL = (gcloud run services describe $SERVICE_NAME ` + --region=$GCP_REGION ` + --project=$GCP_PROJECT_ID ` + --format="value(status.url)").Trim() + +Write-Host "================================================================" +Write-Host " Deployment complete!" +Write-Host " Service URL: $SERVICE_URL" +Write-Host "================================================================" diff --git a/GCR/scripts/04-deploy.sh b/GCR/scripts/04-deploy.sh new file mode 100755 index 0000000..68a05e1 --- /dev/null +++ b/GCR/scripts/04-deploy.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# ============================================================================= +# 04-deploy.sh (Linux) +# Builds the Docker image, pushes it to Artifact Registry, and deploys it +# to Cloud Run — all in one command. +# +# Usage: +# ./GCR/scripts/04-deploy.sh # deploy with tag = git short SHA +# ./GCR/scripts/04-deploy.sh my-tag # deploy with a custom tag +# +# Prerequisites: +# 1. GCR/.env exists and is filled in (copy from GCR/.env.example) +# 2. 01-login.sh has been run (gcloud auth + Docker configured) +# 3. 02-setup-project.sh has been run (APIs enabled, repo created) +# 4. 03-create-secrets.sh has been run (MongoDB secret created) +# 5. Docker daemon is running locally +# +# Windows users: run GCR/scripts/04-deploy.ps1 in PowerShell instead. +# ============================================================================= +set -euo pipefail + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "ERROR: This script is for Linux only." + echo "Windows users: run GCR/scripts/04-deploy.ps1" + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# ── Load .env ───────────────────────────────────────────────────────────────── +ENV_FILE="$SCRIPT_DIR/../.env" +if [[ ! -f "$ENV_FILE" ]]; then + echo "ERROR: $ENV_FILE not found." + echo "Copy GCR/.env.example to GCR/.env and fill in your values first." + exit 1 +fi +# shellcheck disable=SC1090 +source "$ENV_FILE" + +: "${GCP_PROJECT_ID:?GCP_PROJECT_ID is not set in .env}" +: "${GCP_REGION:?GCP_REGION is not set in .env}" +: "${GCP_REPOSITORY:?GCP_REPOSITORY is not set in .env}" +: "${SERVICE_NAME:?SERVICE_NAME is not set in .env}" +: "${MONGODB_DATABASE_NAME:?MONGODB_DATABASE_NAME is not set in .env}" + +# Note: MONGODB_CONNECTION_STRING is stored in Secret Manager (mongodb-connection-string) +# See GCR/README.md for Secret Manager setup + +secret_setup_ready() { + local service_account + service_account="serviceAccount:${GCP_PROJECT_ID}@appspot.gserviceaccount.com" + + gcloud secrets describe "mongodb-connection-string" --project="$GCP_PROJECT_ID" >/dev/null 2>&1 || return 1 + + gcloud secrets get-iam-policy "mongodb-connection-string" \ + --project="$GCP_PROJECT_ID" \ + --flatten="bindings[].members" \ + --filter="bindings.role=roles/secretmanager.secretAccessor AND bindings.members=${service_account}" \ + --format="value(bindings.members)" 2>/dev/null \ + | grep -Fxq "$service_account" +} + +if ! secret_setup_ready; then + echo "" + echo ">>> Required secrets are not fully configured yet." + read -rp " Run GCR/scripts/03-create-secrets.sh now? [y/N]: " RUN_SECRET_SETUP + if [[ "$RUN_SECRET_SETUP" =~ ^[Yy]$ ]]; then + bash "$SCRIPT_DIR/03-create-secrets.sh" + else + echo "" + echo "ERROR: Deployment requires secret setup first." + echo "Run: bash GCR/scripts/03-create-secrets.sh" + exit 1 + fi + + if ! secret_setup_ready; then + echo "" + echo "ERROR: Secret setup check still failing after running 03-create-secrets.sh." + exit 1 + fi +fi + +# ── Determine image tag ──────────────────────────────────────────────────────── +TAG="${1:-}" +if [[ -z "$TAG" ]]; then + # Default to git short SHA if inside a git repo; otherwise use timestamp + if git -C "$REPO_ROOT" rev-parse --short HEAD &>/dev/null; then + TAG="$(git -C "$REPO_ROOT" rev-parse --short HEAD)" + else + TAG="$(date +%Y%m%d%H%M%S)" + fi +fi + +REGISTRY="${GCP_REGION}-docker.pkg.dev" +IMAGE_URI="${REGISTRY}/${GCP_PROJECT_ID}/${GCP_REPOSITORY}/${SERVICE_NAME}:${TAG}" + +echo "================================================================" +echo " Htmx → Cloud Run deployment" +echo "================================================================" +echo " Project: $GCP_PROJECT_ID" +echo " Region: $GCP_REGION" +echo " Service: $SERVICE_NAME" +echo " Image: $IMAGE_URI" +echo "================================================================" +echo "" + +# ── Step 1: Ensure package-lock.json exists (required for `npm ci`) ─────────── +LOCKFILE="$REPO_ROOT/Htmx.ApiDemo/package-lock.json" +if [[ ! -f "$LOCKFILE" ]]; then + echo ">>> package-lock.json not found. Generating it now..." + echo " (This requires node + npm to be installed locally)" + (cd "$REPO_ROOT/Htmx.ApiDemo" && npm install --package-lock-only) + echo " package-lock.json generated. Commit it to the repository." + echo "" +fi + +# ── Step 2: Build the Docker image ──────────────────────────────────────────── +echo ">>> Building Docker image..." +echo " Context: $REPO_ROOT" +echo " Dockerfile: GCR/Dockerfile" +echo "" + +# Build from repo root so the COPY instructions can reach both +# Htmx.ApiDemo/ and Htmx.SourceGenerator/ directories. +docker build \ + --file "$REPO_ROOT/GCR/Dockerfile" \ + --tag "$IMAGE_URI" \ + "$REPO_ROOT" + +echo "" +echo ">>> Image built: $IMAGE_URI" + +# ── Step 3: Push image to Artifact Registry ─────────────────────────────────── +echo "" +echo ">>> Pushing image to Artifact Registry..." +docker push "$IMAGE_URI" +echo ">>> Push complete." + +# ── Step 4: Deploy to Cloud Run via docker-compose.yml ─────────────────────── +echo "" +echo ">>> Deploying to Cloud Run..." + +# Export variables consumed by docker-compose.yml substitution +export IMAGE_URI +export MONGODB_DATABASE_NAME + +gcloud run services replace "$REPO_ROOT/GCR/docker-compose.yml" \ + --region="$GCP_REGION" \ + --project="$GCP_PROJECT_ID" + +# ── Step 4b: Inject MongoDB connection string from Secret Manager ──────────── +echo "" +echo ">>> Injecting MongoDB connection string from Secret Manager..." +gcloud run services update "$SERVICE_NAME" \ + --region="$GCP_REGION" \ + --project="$GCP_PROJECT_ID" \ + --set-secrets="ConnectionStrings__DefaultConnection=mongodb-connection-string:latest" + +# ── Step 5: Make the service publicly accessible ────────────────────────────── +# Remove this block if you want the service to require authentication. +echo "" +echo ">>> Allowing public (unauthenticated) access to the service..." +gcloud run services add-iam-policy-binding "$SERVICE_NAME" \ + --region="$GCP_REGION" \ + --project="$GCP_PROJECT_ID" \ + --member="allUsers" \ + --role="roles/run.invoker" + +# ── Print service URL ───────────────────────────────────────────────────────── +echo "" +SERVICE_URL=$(gcloud run services describe "$SERVICE_NAME" \ + --region="$GCP_REGION" \ + --project="$GCP_PROJECT_ID" \ + --format="value(status.url)") + +echo "================================================================" +echo " Deployment complete!" +echo " Service URL: $SERVICE_URL" +echo "================================================================" diff --git a/Htmx.ApiDemo/wwwroot/css/output.css b/Htmx.ApiDemo/wwwroot/css/output.css index e9351c3..b6071b4 100644 --- a/Htmx.ApiDemo/wwwroot/css/output.css +++ b/Htmx.ApiDemo/wwwroot/css/output.css @@ -911,7 +911,7 @@ --tw-tracking: var(--tracking-tight); letter-spacing: var(--tracking-tight); } - .break-words { + .wrap-break-word { overflow-wrap: break-word; } .whitespace-nowrap {