Intial commit for deployment script p2
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
bin
|
bin
|
||||||
obj
|
obj
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
.env
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Never commit real credentials
|
||||||
|
.env
|
||||||
@@ -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"]
|
||||||
+384
@@ -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`.
|
||||||
@@ -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
|
||||||
Executable
+5
@@ -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 "$@"
|
||||||
+185
@@ -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."
|
||||||
+162
@@ -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."
|
||||||
@@ -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"
|
||||||
Executable
+88
@@ -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"
|
||||||
@@ -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"
|
||||||
Executable
+58
@@ -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"
|
||||||
@@ -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"
|
||||||
Executable
+117
@@ -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"
|
||||||
@@ -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.)"
|
||||||
@@ -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.)"
|
||||||
@@ -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 "================================================================"
|
||||||
Executable
+180
@@ -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 "================================================================"
|
||||||
@@ -911,7 +911,7 @@
|
|||||||
--tw-tracking: var(--tracking-tight);
|
--tw-tracking: var(--tracking-tight);
|
||||||
letter-spacing: var(--tracking-tight);
|
letter-spacing: var(--tracking-tight);
|
||||||
}
|
}
|
||||||
.break-words {
|
.wrap-break-word {
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
.whitespace-nowrap {
|
.whitespace-nowrap {
|
||||||
|
|||||||
Reference in New Issue
Block a user