#!/usr/bin/env bash # Trustico® CaaS for Google Cloud : Optional Extra. The load balancer script for Certificate Manager. # # Purpose # With Certificate Manager a load balancer finds its SSL Certificate through a Certificate Map. A map is a short list : for each hostname, which SSL Certificate to use. The job keeps your SSL Certificate up to date in place, so the map only has to point at it. This script makes sure it does, for each of your hostnames. After that there is nothing left to do, and on every run it simply checks. If you would rather make the map entries yourself in the console, once, you do not need this script. # This file installs the script. Run it once in Cloud Shell and answer its questions. It creates the script in your project, sets it to run every day, and runs it once so that you can see the result. Run it again whenever your answers change, or when Trustico® publishes a new version of the script : it applies your answers again, and leaves what already matches as it is. # # The Questions # Your project. The region of your job. The location of your map, which is global unless you chose otherwise. The map : the installer lists the Certificate Maps in your project so that you can pick yours. The SSL Certificate : the name you gave it in the job configuration file, picked from a list. The hostnames the map should use it for : the names on the SSL Certificate are offered, and the word PRIMARY means the SSL Certificate used when no hostname matches. The service account of the job. The hour of the day the script runs, 05:00 UTC unless you choose another, so that it runs after the job. # # Running the Installer # Open Cloud Shell in the Google Cloud console and run these two commands. # curl -O https://cdn.trustico.com/caas/gcloud/install-certificate-map-script.sh # bash install-certificate-map-script.sh # Answer the questions. Type yes when it shows you your answers. The first time takes a few minutes while Google builds the script. # # The Result # It prints each thing it does, then the result of the first run. "created" lists the entries it added to the map, one for each hostname and named after it, with hyphens in place of dots. "updated" lists the entries it pointed at your SSL Certificate, with what each pointed at before. "unchanged" lists the entries that were right already. If something is wrong, it says what. # # Afterwards # Every day the job runs first, then the script. Its runs are listed in the console under Cloud Run, Services, trustico-caas-certificate-map, Logs. When you add a hostname to the SSL Certificate, run this installer again and include it ; an entry for a hostname you no longer give stays in the map until you delete it. To remove the script, run these two commands in Cloud Shell, with your region. The map stays as it is. # gcloud scheduler jobs delete trustico-caas-certificate-map-daily --location=YOUR-REGION # gcloud run services delete trustico-caas-certificate-map --region=YOUR-REGION # # If you prefer, give the answers on the command line, in this order : project, region, map, SSL Certificate, hostnames separated by commas, service account, then the hour and the location if you want them. set -euo pipefail SERVICE="trustico-caas-certificate-map" SCHEDULE="trustico-caas-certificate-map-daily" FILES="https://cdn.trustico.com/caas/gcloud/certificate-map-entry" fail() { echo "$1" >&2; exit 1; } # ask VARIABLE "question" "default" : one question, Enter keeps the default when there is one. ask() { local var="$1" question="$2" default="${3:-}" answer if [[ -n "$default" ]]; then read -r -p " $question [$default] : " answer answer="${answer:-$default}" else read -r -p " $question : " answer fi printf -v "$var" '%s' "$answer" } # choose VARIABLE "what" item... : one item is offered as the default ; several are numbered to pick from, or another can be typed. choose() { local var="$1" what="$2" shift 2 local items=("$@") answer i if (( ${#items[@]} == 0 )); then ask "$var" "$what" return fi if (( ${#items[@]} == 1 )); then ask "$var" "$what" "${items[0]}" return fi echo " $what :" for i in "${!items[@]}"; do echo " $((i + 1))) ${items[$i]}" done while true; do read -r -p " the number of your choice, or type another : " answer if [[ "$answer" =~ ^[0-9]+$ ]]; then if (( 10#$answer >= 1 && 10#$answer <= ${#items[@]} )); then printf -v "$var" '%s' "${items[$((10#$answer - 1))]}" return fi echo " there is no choice $answer : type a number from 1 to ${#items[@]}" continue fi if [[ -n "$answer" ]]; then printf -v "$var" '%s' "$answer" return fi done } if (( $# >= 6 )); then PROJECT="$1"; REGION="$2"; MAP="$3"; CERTIFICATE="$4"; HOSTNAMES="$5"; ACCOUNT="$6"; HOUR="${7:-5}"; LOCATION="${8:-global}" elif (( $# == 0 )); then echo "=== YOUR VALUES" ask PROJECT "Your project ID" "$(gcloud config get-value project 2>/dev/null || true)" mapfile -t REGIONS < <(gcloud run jobs list --project="$PROJECT" --format=json 2>/dev/null | python3 -c 'import json, sys; print("\n".join(sorted({j["metadata"]["labels"].get("cloud.googleapis.com/location", "") for j in json.load(sys.stdin)} - {""})))' 2>/dev/null || true) choose REGION "The region of your job" "${REGIONS[@]}" ask LOCATION "The location of the Certificate Map and the SSL Certificate object" "global" mapfile -t MAPS < <(gcloud certificate-manager maps list --project="$PROJECT" --location="$LOCATION" --format="value(name.basename())" 2>/dev/null | sort || true) choose MAP "The Certificate Map attached to your load balancer" "${MAPS[@]}" mapfile -t CERTIFICATES < <(gcloud certificate-manager certificates list --project="$PROJECT" --location="$LOCATION" --format="value(name.basename())" 2>/dev/null | sort || true) choose CERTIFICATE "The SSL Certificate object, the name you gave the SSL Certificate in the job configuration file" "${CERTIFICATES[@]}" # The names on the SSL Certificate are the usual hostnames, offered as the default. NAMES_ON_IT="$(gcloud certificate-manager certificates describe "$CERTIFICATE" --project="$PROJECT" --location="$LOCATION" --format="value(sanDnsnames)" 2>/dev/null | tr ';' ',' || true)" ask HOSTNAMES "The hostnames the map serves this SSL Certificate for, comma-separated, or PRIMARY" "$NAMES_ON_IT" mapfile -t ACCOUNTS < <(gcloud iam service-accounts list --project="$PROJECT" --format="value(email)" 2>/dev/null | sort || true) choose ACCOUNT "The service account of the job" "${ACCOUNTS[@]}" ask HOUR "The hour of the day at which the script runs, 0 to 23 in UTC" "5" echo echo " project $PROJECT, region $REGION, map $MAP ($LOCATION), SSL Certificate object $CERTIFICATE, hostnames $HOSTNAMES, service account $ACCOUNT, every day at $HOUR:00 UTC" read -r -p " Create it now ? Type yes to continue : " GO [[ "$GO" == "yes" ]] || { echo "NOTHING CREATED"; exit 0; } else echo "USAGE : bash $0 (and answer the questions), or bash $0 YOUR-PROJECT-ID YOUR-REGION YOUR-MAP YOUR-CERTIFICATE \"HOSTNAME,HOSTNAME\" YOUR-SERVICE-ACCOUNT [HOUR] [LOCATION]" exit 2 fi echo "=== GUARDS" # The project number names the account Google builds the script with, below. NUMBER="$(gcloud projects describe "$PROJECT" --format="value(projectNumber)" 2>/dev/null || true)" [[ -n "$NUMBER" ]] || fail "PROJECT NOT FOUND, OR NO ACCESS TO IT : $PROJECT" [[ "$HOUR" =~ ^0?([0-9]|1[0-9]|2[0-3])$ ]] || fail "THE HOUR MUST BE A NUMBER FROM 0 TO 23 : $HOUR" HOUR=$((10#$HOUR)) [[ -n "$HOSTNAMES" ]] || fail "AT LEAST ONE HOSTNAME, OR PRIMARY, IS NEEDED" gcloud iam service-accounts describe "$ACCOUNT" --project="$PROJECT" >/dev/null 2>&1 || fail "SERVICE ACCOUNT NOT FOUND : $ACCOUNT" gcloud certificate-manager maps describe "$MAP" --project="$PROJECT" --location="$LOCATION" --format="value(name)" >/dev/null 2>&1 || fail "CERTIFICATE MAP NOT FOUND IN $LOCATION : $MAP" gcloud certificate-manager certificates describe "$CERTIFICATE" --project="$PROJECT" --location="$LOCATION" --format="value(name)" >/dev/null 2>&1 || fail "SSL CERTIFICATE OBJECT NOT FOUND IN $LOCATION : $CERTIFICATE (THE JOB CREATES IT ON ITS FIRST RUN)" echo " map $MAP and SSL Certificate object $CERTIFICATE found in $LOCATION" echo "=== APIS" # Google builds the script with Cloud Build and keeps its image in Artifact Registry ; both are enabled here so that the deploy below never stops to ask. gcloud services enable run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com logging.googleapis.com cloudscheduler.googleapis.com certificatemanager.googleapis.com --project="$PROJECT" >/dev/null echo " enabled" # Google builds the script as the Compute Engine default service account of the project. In an organisation created after May 3, 2024 that account starts with no role at all and the build is refused, so it is given the Cloud Run Builder role here, the role Google's own instructions for a deploy from source grant it. Where it already has more, this changes nothing. BUILDER="${NUMBER}-compute@developer.gserviceaccount.com" # --condition=None adds the role without a condition ; without it gcloud stops at a menu of conditions whenever the project already holds a conditional grant, such as an expiring one. gcloud projects add-iam-policy-binding "$PROJECT" --member="serviceAccount:$BUILDER" --role="roles/run.builder" --condition=None >/dev/null echo " $BUILDER may build it" # Cloud Scheduler is not offered in every region Cloud Run is ; the schedule lives in the region of the job, so that region must have it. gcloud scheduler locations describe "$REGION" --project="$PROJECT" >/dev/null 2>&1 || fail "CLOUD SCHEDULER IS NOT AVAILABLE IN $REGION : the schedule cannot live in the region of your job (gcloud scheduler locations list shows the regions it supports)" echo "=== THE SCRIPT" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT curl -fsS -o "$WORK/index.js" "$FILES/index.js" || fail "COULD NOT DOWNLOAD $FILES/index.js" curl -fsS -o "$WORK/package.json" "$FILES/package.json" || fail "COULD NOT DOWNLOAD $FILES/package.json" echo " building and deploying $SERVICE (a few minutes the first time)" # A role granted a moment ago takes a couple of minutes to apply, Google says, and until it does the build is refused. So a deploy that fails is tried again after a minute, up to three times, before the installer gives up. DEPLOYED="" for attempt in 1 2 3; do if gcloud run deploy "$SERVICE" --project="$PROJECT" --region="$REGION" --source="$WORK" --function=certificateMapEntry --base-image=nodejs22 --no-allow-unauthenticated --service-account="$ACCOUNT" --set-env-vars="^|^MAP=$MAP|CERTIFICATE=$CERTIFICATE|HOSTNAMES=$HOSTNAMES|LOCATION=$LOCATION" --quiet; then DEPLOYED="yes" break fi if (( attempt < 3 )); then echo " the deploy did not succeed ; a permission granted a moment ago can take a couple of minutes to apply ; waiting a minute and trying again" sleep 60 fi done [[ -n "$DEPLOYED" ]] || fail "THE DEPLOY FAILED THREE TIMES : the error above says why ; fix it and run this installer again" URL="$(gcloud run services describe "$SERVICE" --project="$PROJECT" --region="$REGION" --format="value(status.url)")" [[ -n "$URL" ]] || fail "THE SCRIPT HAS NO ADDRESS AFTER THE DEPLOY : $SERVICE" echo " deployed : $SERVICE at $URL" echo "=== PERMISSION" gcloud run services add-iam-policy-binding "$SERVICE" --project="$PROJECT" --region="$REGION" --member="serviceAccount:$ACCOUNT" --role="roles/run.invoker" >/dev/null echo " $ACCOUNT may start it" echo "=== THE DAILY SCHEDULE" if gcloud scheduler jobs describe "$SCHEDULE" --project="$PROJECT" --location="$REGION" >/dev/null 2>&1; then gcloud scheduler jobs update http "$SCHEDULE" --project="$PROJECT" --location="$REGION" --schedule="0 $HOUR * * *" --time-zone="Etc/UTC" --uri="$URL" --http-method=POST --oidc-service-account-email="$ACCOUNT" --oidc-token-audience="$URL" >/dev/null echo " exists : $SCHEDULE, set to every day at $(printf '%02d' "$HOUR"):00 UTC" else gcloud scheduler jobs create http "$SCHEDULE" --project="$PROJECT" --location="$REGION" --schedule="0 $HOUR * * *" --time-zone="Etc/UTC" --uri="$URL" --http-method=POST --oidc-service-account-email="$ACCOUNT" --oidc-token-audience="$URL" >/dev/null echo " created : $SCHEDULE, every day at $(printf '%02d' "$HOUR"):00 UTC" fi echo "=== THE FIRST RUN" # The schedule is started once by hand, so that the first run takes the very path every later run takes : the service account, its token, and the permission granted above. A permission granted seconds ago can take a minute or more to apply, and until it does the call is refused with 403 ; the schedule is then started again. Only results written after this moment count. STARTED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" RESULT="" for attempt in 1 2 3 4 5 6; do gcloud scheduler jobs run "$SCHEDULE" --project="$PROJECT" --location="$REGION" >/dev/null echo " started through the schedule, reading the result" for _ in $(seq 1 8); do sleep 5 RESULT="$(gcloud logging read "resource.type=\"cloud_run_revision\" AND resource.labels.service_name=\"$SERVICE\" AND jsonPayload.message:\"CERTIFICATE MAP ENTRIES\" AND timestamp>=\"$STARTED\"" --project="$PROJECT" --limit=1 --format=json 2>/dev/null | python3 -c 'import json, sys; e = json.load(sys.stdin); print(json.dumps(e[0]["jsonPayload"], indent=2) if e else "")' 2>/dev/null || true)" [[ -n "$RESULT" ]] && break done [[ -n "$RESULT" ]] && break LAST_CODE="$(gcloud logging read "resource.type=\"cloud_run_revision\" AND resource.labels.service_name=\"$SERVICE\" AND logName:\"requests\" AND timestamp>=\"$STARTED\"" --project="$PROJECT" --limit=1 --format="value(httpRequest.status)" 2>/dev/null || true)" if [[ "$LAST_CODE" == "403" ]]; then echo " the permission has not applied yet (Google refused the call) ; waiting twenty seconds and starting it again" sleep 20 else echo " no result yet ; waiting twenty seconds and starting it again" sleep 20 fi done [[ -n "$RESULT" ]] || fail "NO RESULT AFTER SEVERAL ATTEMPTS : read the Logs of $SERVICE under Cloud Run, Services, and the row of $SCHEDULE under Cloud Scheduler" echo "$RESULT" | sed 's/^/ /' [[ "$RESULT" != *'"CERTIFICATE MAP ENTRIES FAILED"'* ]] || fail "THE FIRST RUN FAILED : the error above says why ; fix it and run this installer again" echo "DONE : $SERVICE runs every day at $(printf '%02d' "$HOUR"):00 UTC and keeps the entries of $MAP pointed at $CERTIFICATE ; its runs are under Cloud Run, Services, $SERVICE, Logs"