Last active
March 12, 2026 00:08
-
-
Save jeffreyolchovy/0d0c63ef97c0599de0e0a668bbc2b3f9 to your computer and use it in GitHub Desktop.
Transfer Gemini Enterprise license seats between GCP projects
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ============================================================================= | |
| # Gemini Enterprise License Transfer - Configuration | |
| # ============================================================================= | |
| # Copy this file to .env and fill in your values: | |
| # cp .env.example .env | |
| # | |
| # Then run: | |
| # ./transfer-licenses.sh # execute the transfer | |
| # ./transfer-licenses.sh --dry-run # preview the curl commands without executing | |
| # ============================================================================= | |
| # --------------------------------------------------------------------------- | |
| # Billing Account (found on Gemini Enterprise "Manage subscription" page) | |
| # --------------------------------------------------------------------------- | |
| # Navigate to: Manage subscription -> select billing account -> click subscription | |
| # The detail page shows both BILLING_ACCOUNT_ID and BILLING_ACCOUNT_LICENSE_CONFIG_ID | |
| BILLING_ACCOUNT_ID= | |
| BILLING_ACCOUNT_LICENSE_CONFIG_ID= | |
| # --------------------------------------------------------------------------- | |
| # Source Project (retract licenses FROM this project) | |
| # --------------------------------------------------------------------------- | |
| SOURCE_PROJECT_NUMBER= | |
| # --------------------------------------------------------------------------- | |
| # Target Project (distribute licenses TO this project) | |
| # --------------------------------------------------------------------------- | |
| TARGET_PROJECT_NUMBER= | |
| # --------------------------------------------------------------------------- | |
| # Quota Project | |
| # --------------------------------------------------------------------------- | |
| # The project number where you have the "Service Usage Consumer" role. | |
| # This can be the same as TARGET_PROJECT_NUMBER if you have the necessary | |
| # permissions on that project. Note: use the literal value, not a variable reference. | |
| MY_PROJECT_NUMBER= | |
| # --------------------------------------------------------------------------- | |
| # Location | |
| # --------------------------------------------------------------------------- | |
| # "us" -> US multi-region (endpoint prefix: us-) | |
| # "eu" -> EU multi-region (endpoint prefix: eu-) | |
| # "global" -> Global multi-region (no endpoint prefix) | |
| LOCATION=global | |
| # --------------------------------------------------------------------------- | |
| # License Config IDs (found on "Manage users" page of Gemini Enterprise UI) | |
| # --------------------------------------------------------------------------- | |
| # The license config ID on the SOURCE project (required for retract step). | |
| SOURCE_LICENSE_CONFIG_ID= | |
| # The license config ID on the TARGET project (optional for distribute step). | |
| # Leave empty to auto-create a new LicenseConfig on the target project. | |
| DISTRIBUTE_LICENSE_CONFIG_ID= | |
| # --------------------------------------------------------------------------- | |
| # License Count | |
| # --------------------------------------------------------------------------- | |
| # Number of license seats to transfer. Use 1 for testing. | |
| LICENSE_COUNT=1 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # ============================================================================= | |
| # transfer-licenses.sh | |
| # Gemini Enterprise License Transfer between GCP Projects | |
| # | |
| # Retracts license seats from a source project back to a billing account, | |
| # then distributes them to a target project. | |
| # | |
| # Usage: | |
| # ./transfer-licenses.sh # execute the transfer | |
| # ./transfer-licenses.sh --dry-run # preview curl commands without executing | |
| # ./transfer-licenses.sh --help # show usage | |
| # ============================================================================= | |
| set -euo pipefail | |
| # ------------------------------- constants ----------------------------------- | |
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| ENV_FILE="${SCRIPT_DIR}/.env" | |
| API_VERSION="v1alpha" | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| CYAN='\033[0;36m' | |
| BOLD='\033[1m' | |
| RESET='\033[0m' | |
| # ------------------------------- helpers ------------------------------------- | |
| info() { printf "${CYAN}[INFO]${RESET} %s\n" "$*"; } | |
| success() { printf "${GREEN}[OK]${RESET} %s\n" "$*"; } | |
| warn() { printf "${YELLOW}[WARN]${RESET} %s\n" "$*"; } | |
| error() { printf "${RED}[ERROR]${RESET} %s\n" "$*" >&2; } | |
| fatal() { error "$@"; exit 1; } | |
| usage() { | |
| cat <<EOF | |
| Usage: $(basename "$0") [OPTIONS] | |
| Transfer Gemini Enterprise license seats between GCP projects. | |
| Options: | |
| --retract-only Only retract licenses from the source project. | |
| --distribute-only Only distribute licenses to the target project. | |
| --dry-run Print the curl commands without executing them. | |
| --help Show this help message and exit. | |
| If neither --retract-only nor --distribute-only is specified, both steps | |
| are run in sequence (retract then distribute). | |
| Configuration: | |
| All parameters are read from ${ENV_FILE}. | |
| Copy .env.example to .env and fill in your values before running. | |
| Environment variables override values in .env. | |
| EOF | |
| } | |
| # ------------------------------- parse flags --------------------------------- | |
| DRY_RUN=false | |
| RUN_RETRACT=true | |
| RUN_DISTRIBUTE=true | |
| MODE_EXPLICIT=false | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --dry-run) DRY_RUN=true; shift ;; | |
| --retract-only) RUN_RETRACT=true; RUN_DISTRIBUTE=false; MODE_EXPLICIT=true; shift ;; | |
| --distribute-only) RUN_RETRACT=false; RUN_DISTRIBUTE=true; MODE_EXPLICIT=true; shift ;; | |
| --help|-h) usage; exit 0 ;; | |
| *) fatal "Unknown option: $1. Use --help for usage." ;; | |
| esac | |
| done | |
| # ------------------------------- load .env ----------------------------------- | |
| if [[ ! -f "$ENV_FILE" ]]; then | |
| fatal ".env file not found at ${ENV_FILE}\n Run: cp .env.example .env and fill in your values." | |
| fi | |
| # Source the env file, but do NOT overwrite variables already set in the | |
| # environment. This lets callers override any value: | |
| # SOURCE_PROJECT_NUMBER=12345 ./transfer-licenses.sh | |
| while IFS='=' read -r key value; do | |
| # Skip blank lines and comments | |
| [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue | |
| # Trim leading/trailing whitespace from key | |
| key=$(echo "$key" | xargs) | |
| # Strip surrounding quotes (single or double) and whitespace from value | |
| value=$(echo "$value" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^["'\'']//' -e 's/["'\''"]$//') | |
| # Only set the variable if it is not already present in the environment | |
| if [[ -z "${!key+x}" ]]; then | |
| export "$key=$value" | |
| fi | |
| done < "$ENV_FILE" | |
| # ------------------------------- validate ------------------------------------ | |
| REQUIRED_VARS=( | |
| BILLING_ACCOUNT_ID | |
| BILLING_ACCOUNT_LICENSE_CONFIG_ID | |
| MY_PROJECT_NUMBER | |
| LICENSE_COUNT | |
| ) | |
| # Add mode-specific required vars | |
| if $RUN_RETRACT; then | |
| REQUIRED_VARS+=(SOURCE_PROJECT_NUMBER SOURCE_LICENSE_CONFIG_ID) | |
| fi | |
| if $RUN_DISTRIBUTE; then | |
| REQUIRED_VARS+=(TARGET_PROJECT_NUMBER) | |
| fi | |
| missing=() | |
| for var in "${REQUIRED_VARS[@]}"; do | |
| if [[ -z "${!var:-}" ]]; then | |
| missing+=("$var") | |
| fi | |
| done | |
| if [[ ${#missing[@]} -gt 0 ]]; then | |
| fatal "Missing required variables in .env:\n $(printf '%s\n ' "${missing[@]}")" | |
| fi | |
| # LOCATION is allowed to be empty (means Global) | |
| LOCATION="${LOCATION:-}" | |
| # ------------------------------- derived values ------------------------------ | |
| # Derive the endpoint location prefix from LOCATION | |
| case "${LOCATION}" in | |
| us) ENDPOINT_LOCATION="us-" ;; | |
| eu) ENDPOINT_LOCATION="eu-" ;; | |
| ""|global) ENDPOINT_LOCATION="" ;; | |
| *) fatal "Invalid LOCATION '${LOCATION}'. Must be 'us', 'eu', 'global', or empty." ;; | |
| esac | |
| BASE_URL="https://${ENDPOINT_LOCATION}discoveryengine.googleapis.com/${API_VERSION}" | |
| BILLING_BASE="${BASE_URL}/billingAccounts/${BILLING_ACCOUNT_ID}/billingAccountLicenseConfigs/${BILLING_ACCOUNT_LICENSE_CONFIG_ID}" | |
| # ------------------------------- pre-flight ---------------------------------- | |
| info "Running pre-flight checks..." | |
| command -v gcloud >/dev/null 2>&1 || fatal "'gcloud' not found. Install the Google Cloud SDK first." | |
| command -v curl >/dev/null 2>&1 || fatal "'curl' not found." | |
| command -v jq >/dev/null 2>&1 || warn "'jq' not found. Responses will not be pretty-printed." | |
| # Verify we have a valid access token | |
| if ! gcloud auth print-access-token >/dev/null 2>&1; then | |
| warn "No active gcloud credentials. Launching interactive login..." | |
| gcloud auth login --no-launch-browser || fatal "Authentication failed." | |
| fi | |
| success "gcloud authentication OK" | |
| # ------------------------------- summary ------------------------------------- | |
| echo "" | |
| printf "${BOLD}╔══════════════════════════════════════════════════════════════╗${RESET}\n" | |
| printf "${BOLD}║ Gemini Enterprise License Transfer ║${RESET}\n" | |
| printf "${BOLD}╚══════════════════════════════════════════════════════════════╝${RESET}\n" | |
| echo "" | |
| printf " Billing Account ID: %s\n" "$BILLING_ACCOUNT_ID" | |
| printf " Billing License Config ID: %s\n" "$BILLING_ACCOUNT_LICENSE_CONFIG_ID" | |
| if $RUN_RETRACT; then | |
| printf " Source Project Number: %s\n" "$SOURCE_PROJECT_NUMBER" | |
| printf " Source License Config ID: %s\n" "$SOURCE_LICENSE_CONFIG_ID" | |
| fi | |
| if $RUN_DISTRIBUTE; then | |
| printf " Target Project Number: %s\n" "$TARGET_PROJECT_NUMBER" | |
| printf " Distribute License Config ID: %s\n" "${DISTRIBUTE_LICENSE_CONFIG_ID:-"(auto-create)"}" | |
| fi | |
| printf " Quota Project (MY_PROJECT): %s\n" "$MY_PROJECT_NUMBER" | |
| printf " Location: %s\n" "${LOCATION:-"(global)"}" | |
| printf " Endpoint Location Prefix: %s\n" "${ENDPOINT_LOCATION:-"(none)"}" | |
| printf " License Count: %s\n" "$LICENSE_COUNT" | |
| echo "" | |
| # Show mode | |
| if $RUN_RETRACT && $RUN_DISTRIBUTE; then | |
| printf " ${CYAN}Mode: Retract + Distribute${RESET}\n" | |
| elif $RUN_RETRACT; then | |
| printf " ${CYAN}Mode: Retract only${RESET}\n" | |
| else | |
| printf " ${CYAN}Mode: Distribute only${RESET}\n" | |
| fi | |
| if $DRY_RUN; then | |
| printf " ${YELLOW}DRY RUN (no API calls will be made)${RESET}\n" | |
| fi | |
| echo "" | |
| # ------------------------------- confirm ------------------------------------- | |
| read -r -p "Proceed with the transfer? [y/N] " confirm | |
| case "$confirm" in | |
| [yY]|[yY][eE][sS]) ;; | |
| *) info "Aborted."; exit 0 ;; | |
| esac | |
| echo "" | |
| # ------------------------------- execute API call ---------------------------- | |
| # Runs a curl command and handles the response. | |
| # Arguments: | |
| # $1 - step label (e.g. "Retract" or "Distribute") | |
| # $2 - URL | |
| # $3 - JSON body | |
| execute_api_call() { | |
| local label="$1" | |
| local url="$2" | |
| local body="$3" | |
| echo "---" | |
| info "${label}: URL" | |
| echo " ${url}" | |
| echo "" | |
| info "${label}: Request Body" | |
| if command -v jq >/dev/null 2>&1; then | |
| echo "$body" | jq . | |
| else | |
| echo " ${body}" | |
| fi | |
| echo "" | |
| if $DRY_RUN; then | |
| info "${label}: curl command (dry run)" | |
| cat <<CURL_CMD | |
| curl -X POST \\ | |
| -H "Authorization: Bearer \$(gcloud auth print-access-token)" \\ | |
| -H "Content-Type: application/json" \\ | |
| -H "X-Goog-User-Project: ${MY_PROJECT_NUMBER}" \\ | |
| -d '${body}' \\ | |
| "${url}" | |
| CURL_CMD | |
| echo "" | |
| success "${label}: Dry run complete (no request sent)" | |
| return 0 | |
| fi | |
| local http_code | |
| local response | |
| response=$(curl -s -w "\n%{http_code}" -X POST \ | |
| -H "Authorization: Bearer $(gcloud auth print-access-token)" \ | |
| -H "Content-Type: application/json" \ | |
| -H "X-Goog-User-Project: ${MY_PROJECT_NUMBER}" \ | |
| -d "${body}" \ | |
| "${url}") | |
| # The last line is the HTTP status code | |
| http_code=$(echo "$response" | tail -n1) | |
| response=$(echo "$response" | sed '$d') | |
| info "${label}: HTTP ${http_code}" | |
| if command -v jq >/dev/null 2>&1; then | |
| echo "$response" | jq . 2>/dev/null || echo "$response" | |
| else | |
| echo "$response" | |
| fi | |
| echo "" | |
| if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then | |
| success "${label}: Completed successfully (HTTP ${http_code})" | |
| return 0 | |
| else | |
| error "${label}: Failed with HTTP ${http_code}" | |
| return 1 | |
| fi | |
| } | |
| # ------------------------------- Step 1: Retract ----------------------------- | |
| retract_ok=true | |
| if $RUN_RETRACT; then | |
| RETRACT_URL="${BILLING_BASE}:retractLicenseConfig" | |
| RETRACT_BODY=$(cat <<JSON | |
| { | |
| "licenseConfig": "projects/${SOURCE_PROJECT_NUMBER}/locations/${LOCATION}/licenseConfigs/${SOURCE_LICENSE_CONFIG_ID}", | |
| "licenseCount": ${LICENSE_COUNT} | |
| } | |
| JSON | |
| ) | |
| if $RUN_DISTRIBUTE; then | |
| info "Step 1/2: Retracting ${LICENSE_COUNT} license(s) from project ${SOURCE_PROJECT_NUMBER}..." | |
| else | |
| info "Retracting ${LICENSE_COUNT} license(s) from project ${SOURCE_PROJECT_NUMBER}..." | |
| fi | |
| echo "" | |
| execute_api_call "Retract" "$RETRACT_URL" "$RETRACT_BODY" || retract_ok=false | |
| echo "" | |
| # Ask whether to continue if retract failed and distribute is also planned | |
| if $RUN_DISTRIBUTE && ! $DRY_RUN && ! $retract_ok; then | |
| warn "Retract step failed. The distribute step may still work if the licenses are already at the billing account level." | |
| read -r -p "Continue with distribute? [y/N] " cont | |
| case "$cont" in | |
| [yY]|[yY][eE][sS]) ;; | |
| *) fatal "Aborted after retract failure." ;; | |
| esac | |
| echo "" | |
| fi | |
| fi | |
| # ------------------------------- Step 2: Distribute -------------------------- | |
| distribute_ok=true | |
| if $RUN_DISTRIBUTE; then | |
| DISTRIBUTE_URL="${BILLING_BASE}:distributeLicenseConfig" | |
| # Build the distribute JSON body; omit licenseConfigId if not specified | |
| if [[ -n "${DISTRIBUTE_LICENSE_CONFIG_ID:-}" ]]; then | |
| DISTRIBUTE_BODY=$(cat <<JSON | |
| { | |
| "projectNumber": "${TARGET_PROJECT_NUMBER}", | |
| "location": "${LOCATION}", | |
| "licenseCount": ${LICENSE_COUNT}, | |
| "licenseConfigId": "${DISTRIBUTE_LICENSE_CONFIG_ID}" | |
| } | |
| JSON | |
| ) | |
| else | |
| DISTRIBUTE_BODY=$(cat <<JSON | |
| { | |
| "projectNumber": "${TARGET_PROJECT_NUMBER}", | |
| "location": "${LOCATION}", | |
| "licenseCount": ${LICENSE_COUNT} | |
| } | |
| JSON | |
| ) | |
| fi | |
| if $RUN_RETRACT; then | |
| info "Step 2/2: Distributing ${LICENSE_COUNT} license(s) to project ${TARGET_PROJECT_NUMBER}..." | |
| else | |
| info "Distributing ${LICENSE_COUNT} license(s) to project ${TARGET_PROJECT_NUMBER}..." | |
| fi | |
| echo "" | |
| execute_api_call "Distribute" "$DISTRIBUTE_URL" "$DISTRIBUTE_BODY" || distribute_ok=false | |
| fi | |
| # ------------------------------- Summary ------------------------------------- | |
| echo "" | |
| printf "${BOLD}══════════════════════════════════════════════════════════════${RESET}\n" | |
| printf "${BOLD} Summary${RESET}\n" | |
| printf "${BOLD}══════════════════════════════════════════════════════════════${RESET}\n" | |
| if $DRY_RUN; then | |
| printf " ${YELLOW}DRY RUN — no API calls were made${RESET}\n" | |
| fi | |
| if $RUN_RETRACT; then | |
| if $retract_ok; then | |
| printf " Retract: ${GREEN}OK${RESET}\n" | |
| else | |
| printf " Retract: ${RED}FAILED${RESET}\n" | |
| fi | |
| fi | |
| if $RUN_DISTRIBUTE; then | |
| if $distribute_ok; then | |
| printf " Distribute: ${GREEN}OK${RESET}\n" | |
| else | |
| printf " Distribute: ${RED}FAILED${RESET}\n" | |
| fi | |
| fi | |
| echo "" | |
| all_ok=true | |
| $RUN_RETRACT && ! $retract_ok && all_ok=false | |
| $RUN_DISTRIBUTE && ! $distribute_ok && all_ok=false | |
| if $all_ok; then | |
| success "License transfer complete!" | |
| exit 0 | |
| else | |
| error "One or more steps failed. Review the output above." | |
| exit 1 | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment