scripts/update_cloudsql_authorized_networks.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
set -euo pipefail
# Update Cloud SQL authorized networks with current Cloudflare IPv4 ranges.
# By default this MERGES existing authorized networks with Cloudflare IPs.
# Use --replace to overwrite with Cloudflare-only ranges.
CF_IPS_URL="https://api.cloudflare.com/client/v4/ips"
usage() {
cat <<'EOF'
Usage:
scripts/update_cloudsql_authorized_networks.sh [options]
Interactive defaults:
- If --project is omitted, uses current gcloud project or asks you to choose one.
- If --instance is omitted, lists Cloud SQL instances and asks you to choose one/all/many.
Options:
--project <id> GCP project ID (optional)
--instance <name> Cloud SQL instance name (optional)
--replace Replace existing authorized networks (Cloudflare IPv4 only)
--dry-run Print the gcloud patch command but do not execute it
--cloudflare-url <url> Override Cloudflare IP API URL (default: https://api.cloudflare.com/client/v4/ips)
-h, --help Show this help
Examples:
scripts/update_cloudsql_authorized_networks.sh
scripts/update_cloudsql_authorized_networks.sh --project capgo-394818
scripts/update_cloudsql_authorized_networks.sh --project capgo-394818 --instance capgo-hk
scripts/update_cloudsql_authorized_networks.sh --project capgo-394818 --dry-run
scripts/update_cloudsql_authorized_networks.sh --project capgo-394818 --instance capgo-hk --replace
EOF
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Error: required command '$1' is not installed." >&2
exit 1
fi
}
require_arg() {
local flag="$1"
local value="${2:-}"
if [[ -z "$value" || "$value" == --* ]]; then
echo "Error: $flag requires a value." >&2
exit 1
fi
}
PROJECT_ID=""
INSTANCE_NAME=""
REPLACE="false"
DRY_RUN="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--project)
require_arg "$1" "${2:-}"
PROJECT_ID="$2"
shift 2
;;
--instance)
require_arg "$1" "${2:-}"
INSTANCE_NAME="$2"
shift 2
;;
--replace)
REPLACE="true"
shift
;;
--dry-run)
DRY_RUN="true"
shift
;;
--cloudflare-url)
require_arg "$1" "${2:-}"
CF_IPS_URL="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage
exit 1
;;
esac
done
require_cmd gcloud
require_cmd curl
require_cmd bun
pick_project_if_needed() {
if [[ -n "$PROJECT_ID" ]]; then
return
fi
local current
current="$(gcloud config get-value project 2>/dev/null | tr -d '\r')"
if [[ -n "$current" && "$current" != "(unset)" ]]; then
PROJECT_ID="$current"
echo "==> Using current gcloud project: $PROJECT_ID"
return
fi
echo "==> No project configured. Select a project:"
local project_ids=()
local line
while IFS= read -r line; do
[[ -n "$line" ]] && project_ids+=("$line")
done < <(gcloud projects list --format='value(projectId)')
if [[ "${#project_ids[@]}" -eq 0 ]]; then
echo "Error: no GCP projects found for your current gcloud auth." >&2
exit 1
fi
local i=1
for id in "${project_ids[@]}"; do
printf " %2d) %s\n" "$i" "$id"
i=$((i + 1))
done
local choice
while true; do
printf "Enter project number [1-%d]: " "${#project_ids[@]}"
read -r choice
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#project_ids[@]} )); then
PROJECT_ID="${project_ids[$((choice - 1))]}"
echo "==> Selected project: $PROJECT_ID"
break
fi
echo "Invalid selection."
done
}
select_instances_if_needed() {
if [[ -n "$INSTANCE_NAME" ]]; then
TARGET_INSTANCES=("$INSTANCE_NAME")
return
fi
echo "==> Listing Cloud SQL instances for project: $PROJECT_ID"
local instances_json
instances_json="$(gcloud sql instances list --project="$PROJECT_ID" --format=json)"
INSTANCE_NAMES=()
local row
while IFS=$'\t' read -r name region state db_version; do
[[ -z "$name" ]] && continue
INSTANCE_NAMES+=("$name")
printf " %2d) %-36s region=%-16s state=%-12s db=%s\n" "${#INSTANCE_NAMES[@]}" "$name" "$region" "$state" "$db_version"
done < <(printf '%s' "$instances_json" | bun -e '
const raw = await Bun.stdin.text()
const data = JSON.parse(raw)
if (!Array.isArray(data)) process.exit(0)
for (const inst of data) {
const name = inst?.name ?? ""
const region = inst?.region ?? ""
const state = inst?.state ?? ""
const db = inst?.databaseVersion ?? ""
console.log([name, region, state, db].join("\t"))
}
')
if [[ "${#INSTANCE_NAMES[@]}" -eq 0 ]]; then
echo "Error: no Cloud SQL instances found in project '$PROJECT_ID'." >&2
exit 1
fi
echo "Select instances: number, comma list (e.g. 1,3), or 'all'"
local choice
while true; do
printf "Enter choice: "
read -r choice
if [[ "$choice" == "all" ]]; then
TARGET_INSTANCES=("${INSTANCE_NAMES[@]}")
break
fi
if [[ "$choice" =~ ^[0-9]+(,[0-9]+)*$ ]]; then
TARGET_INSTANCES=()
local invalid="false"
IFS=',' read -r -a picked <<< "$choice"
for idx in "${picked[@]}"; do
if (( idx < 1 || idx > ${#INSTANCE_NAMES[@]} )); then
invalid="true"
break
fi
TARGET_INSTANCES+=("${INSTANCE_NAMES[$((idx - 1))]}")
done
if [[ "$invalid" == "false" && "${#TARGET_INSTANCES[@]}" -gt 0 ]]; then
break
fi
fi
echo "Invalid selection."
done
}
build_final_csv_for_instance() {
local instance_name="$1"
local final_csv="$CF_CSV"
if [[ "$REPLACE" != "true" ]]; then
local existing_json existing_csv
existing_json="$(gcloud sql instances describe "$instance_name" --project="$PROJECT_ID" --format=json)"
existing_csv="$(printf '%s' "$existing_json" | bun -e '
const raw = await Bun.stdin.text()
const data = JSON.parse(raw)
const entries = data?.settings?.ipConfiguration?.authorizedNetworks ?? []
const values = entries
.map((entry) => entry?.value ?? "")
.map((value) => value.trim())
.filter(Boolean)
console.log(values.join(","))
')"
final_csv="$(printf '%s\n%s\n' "$existing_csv" "$CF_CSV" \
| tr ',' '\n' \
| awk 'NF' \
| sort -u \
| paste -sd, -)"
fi
if [[ -z "$final_csv" ]]; then
echo "Error: final authorized networks list is empty for instance '$instance_name'." >&2
exit 1
fi
printf '%s' "$final_csv"
}
apply_for_instance() {
local instance_name="$1"
local final_csv count
echo "==> Preparing authorized networks for instance: $instance_name"
final_csv="$(build_final_csv_for_instance "$instance_name")"
count="$(printf '%s' "$final_csv" | tr ',' '\n' | awk 'NF' | wc -l | tr -d ' ')"
echo "==> Final CIDR count for $instance_name: $count"
local patch_cmd=(
gcloud sql instances patch "$instance_name"
"--project=$PROJECT_ID"
"--authorized-networks=$final_csv"
"--quiet"
)
if [[ "$DRY_RUN" == "true" ]]; then
echo "==> Dry run command for $instance_name:"
printf '%q ' "${patch_cmd[@]}"
echo
return
fi
echo "==> Applying authorized networks on $instance_name..."
"${patch_cmd[@]}"
echo "==> Done for $instance_name"
}
echo "==> Fetching Cloudflare IPv4 ranges from: $CF_IPS_URL"
CF_JSON="$(curl -fsSL "$CF_IPS_URL")"
CF_CSV="$(printf '%s' "$CF_JSON" | bun -e '
const raw = await Bun.stdin.text()
const data = JSON.parse(raw)
if (!data?.success || !Array.isArray(data?.result?.ipv4_cidrs)) {
console.error("Invalid Cloudflare IP API response")
process.exit(1)
}
console.log(data.result.ipv4_cidrs.join(","))
')"
if [[ -z "$CF_CSV" ]]; then
echo "Error: Cloudflare IPv4 list is empty." >&2
exit 1
fi
pick_project_if_needed
select_instances_if_needed
echo "==> Project: $PROJECT_ID"
echo "==> Instances selected: ${TARGET_INSTANCES[*]}"
echo "==> Mode: $([[ "$REPLACE" == "true" ]] && echo 'replace' || echo 'merge')"
echo "==> Dry run: $DRY_RUN"
for inst in "${TARGET_INSTANCES[@]}"; do
apply_for_instance "$inst"
done
echo "==> Completed."