Created
October 7, 2025 05:03
-
-
Save jth0/bcebb77b4817c472008f83e31d3e7f85 to your computer and use it in GitHub Desktop.
Revisions
-
jth0 created this gist
Oct 7, 2025 .There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,762 @@ #!/usr/bin/env bash # Script for building & pushing a docker image to Google Cloud (GCP) Artifact Registry (GAR) # Not a ton of "special sauce" here, and you may be able to tell by the emoji that it's been # AI-enhanced. Major happy paths tested, not all edge cases have been fully vetted though. # So far only found one hallucination and one set of parameters where code was added but the # help output and runtime didn't actually reference the var/param or run the code... set -euo pipefail # Script configuration and defaults SCRIPT_NAME="$(basename "$0")" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Default values - these are all overridable with CLI parameters DEFAULT_REGION="us-central1" # Let's go, No Coast! DEFAULT_PROJECT="MyProjectNameHere" DEFAULT_REPO="MyhRepoNameHere" DEFAULT_IMAGE="MyImageNameHere" DEFAULT_TAG="test" # A novel idea... DEFAULT_R_VERSION="4.4.3" # Manually put here, but should get parsed/updated based on base image selected and/or latest found DEFAULT_PYTHON_VERSION="3.12.11" # ditto # Initialize variables with defaults REGION="${DEFAULT_REGION}" PROJECT="${DEFAULT_PROJECT}" REPO="${DEFAULT_REPO}" IMAGE="${DEFAULT_IMAGE}" TAG="${DEFAULT_TAG}" R_VERSION="${DEFAULT_R_VERSION}" PYTHON_VERSION="${DEFAULT_PYTHON_VERSION}" # Variables for Dockerfile parsing DOCKERFILE_BASE_IMAGE="" DOCKERFILE_R_VERSION="" DOCKERFILE_PYTHON_VERSION="" DOCKERFILE_UPDATED=false # Configuration flags SKIP_CONFIRMATION=false SKIP_PUSH=false NO_CACHE=true PULL=true PLATFORM="linux/amd64" AUTO_UPDATE=false # Track if versions were explicitly specified R_VERSION_SPECIFIED=false PYTHON_VERSION_SPECIFIED=false # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' MAGENTA='\033[0;35m' NC='\033[0m' # No Color # Logging functions log_info() { echo -e "${BLUE}ℹ️ $1${NC}" } log_success() { echo -e "${GREEN}✅ $1${NC}" } log_warning() { echo -e "${YELLOW}⚠️ $1${NC}" } log_error() { echo -e "${RED}❌ $1${NC}" >&2 } log_header() { echo -e "${CYAN}🐳 $1${NC}" } log_update() { echo -e "${MAGENTA}🔄 $1${NC}" } # Help function show_help() { cat << EOF ${SCRIPT_NAME} - Build and deploy Docker images to Google Artifact Registry USAGE: ${SCRIPT_NAME} [OPTIONS] OPTIONS: -t, --tag TAG Docker image tag (default: ${DEFAULT_TAG}) -r, --region REGION GCP region (default: ${DEFAULT_REGION}) -p, --project PROJECT GCP project ID (default: ${DEFAULT_PROJECT}) -R, --repo REPO Artifact Registry repository (default: ${DEFAULT_REPO}) -i, --image IMAGE Image name (default: ${DEFAULT_IMAGE}) --r-version VERSION R version (default: ${DEFAULT_R_VERSION}) --python-version VERSION Python version (default: ${DEFAULT_PYTHON_VERSION}) --platform PLATFORM Target platform (default: ${PLATFORM}) --cache Use Docker cache (default: no-cache) --no-pull Don't pull base images --skip-push Build only, don't push to registry --auto-update Automatically check for and prompt to update base image --yes Skip confirmation prompt -h, --help Show this help message EXAMPLES: ${SCRIPT_NAME} --tag v1.2.3 ${SCRIPT_NAME} --tag latest --yes ${SCRIPT_NAME} --tag dev --r-version 4.4.1 --python-version 3.11.10 ${SCRIPT_NAME} --skip-push --tag local-test --auto-update EOF } # Parse command line arguments parse_args() { while [[ $# -gt 0 ]]; do case $1 in -t|--tag) TAG="$2" shift 2 ;; -r|--region) REGION="$2" shift 2 ;; -p|--project) PROJECT="$2" shift 2 ;; -R|--repo) REPO="$2" shift 2 ;; -i|--image) IMAGE="$2" shift 2 ;; --r-version) R_VERSION="$2" R_VERSION_SPECIFIED=true shift 2 ;; --python-version) PYTHON_VERSION="$2" PYTHON_VERSION_SPECIFIED=true shift 2 ;; --platform) PLATFORM="$2" shift 2 ;; --cache) NO_CACHE=false shift ;; --no-pull) PULL=false shift ;; --skip-push) SKIP_PUSH=true shift ;; --auto-update) AUTO_UPDATE=true shift ;; --yes) SKIP_CONFIRMATION=true shift ;; -h|--help) show_help exit 0 ;; *) log_error "Unknown option: $1" echo "Use --help for usage information." exit 1 ;; esac done } # Parse Dockerfile to extract base image and versions parse_dockerfile() { local dockerfile="${SCRIPT_DIR}/Dockerfile" if [[ ! -f "$dockerfile" ]]; then log_error "Dockerfile not found in ${SCRIPT_DIR}" exit 1 fi log_info "Parsing Dockerfile for base image and versions..." # Extract base image from FROM instruction (get the active one, not commented) DOCKERFILE_BASE_IMAGE=$(grep -E "^FROM " "$dockerfile" | head -1 | awk '{print $2}' | tr -d '\r') if [[ -z "$DOCKERFILE_BASE_IMAGE" ]]; then log_error "Could not find FROM instruction in Dockerfile" exit 1 fi # Parse versions from the base image tag # Format: rstudio/workbench-session:ubuntu2204-r4.4.3_4.3.3-py3.12.11_3.11.13 local image_tag="${DOCKERFILE_BASE_IMAGE##*:}" # Extract R versions (primary and secondary) # Pattern: r4.4.3_4.3.3 -> we want the first one (4.4.3) if [[ "$image_tag" =~ r([0-9]+\.[0-9]+\.[0-9]+)_ ]]; then DOCKERFILE_R_VERSION="${BASH_REMATCH[1]}" else log_warning "Could not parse R version from base image tag: $image_tag" DOCKERFILE_R_VERSION="${DEFAULT_R_VERSION}" fi # Extract Python versions (primary and secondary) # Pattern: py3.12.11_3.11.13 -> we want the first one (3.12.11) if [[ "$image_tag" =~ py([0-9]+\.[0-9]+\.[0-9]+)_ ]]; then DOCKERFILE_PYTHON_VERSION="${BASH_REMATCH[1]}" else log_warning "Could not parse Python version from base image tag: $image_tag" DOCKERFILE_PYTHON_VERSION="${DEFAULT_PYTHON_VERSION}" fi log_success "Dockerfile parsed successfully" echo " • Base Image: ${DOCKERFILE_BASE_IMAGE}" echo " • R Version (from tag): ${DOCKERFILE_R_VERSION}" echo " • Python Version (from tag): ${DOCKERFILE_PYTHON_VERSION}" echo } # Update Dockerfile with new base image update_dockerfile_base_image() { local new_base_image="$1" local dockerfile="${SCRIPT_DIR}/Dockerfile" log_update "Updating Dockerfile with new base image: ${new_base_image}" # Create backup cp "$dockerfile" "${dockerfile}.backup.$(date +%Y%m%d_%H%M%S)" # Update the FROM line sed -i.tmp "s|^FROM .*|FROM ${new_base_image}|" "$dockerfile" rm -f "${dockerfile}.tmp" DOCKERFILE_BASE_IMAGE="$new_base_image" DOCKERFILE_UPDATED=true log_success "Dockerfile updated successfully" } # Check version specifications and warn if unnecessary check_version_warnings() { local warnings_shown=false if [[ "$R_VERSION_SPECIFIED" = true ]]; then if [[ "$R_VERSION" = "$DOCKERFILE_R_VERSION" ]]; then log_warning "R version ${R_VERSION} matches the base image version" log_info " The --r-version parameter is not needed - the base image already has R ${DOCKERFILE_R_VERSION}" warnings_shown=true else log_info "Custom R version specified: ${R_VERSION} (will override base image's ${DOCKERFILE_R_VERSION})" fi fi if [[ "$PYTHON_VERSION_SPECIFIED" = true ]]; then if [[ "$PYTHON_VERSION" = "$DOCKERFILE_PYTHON_VERSION" ]]; then log_warning "Python version ${PYTHON_VERSION} matches the base image version" log_info " The --python-version parameter is not needed - the base image already has Python ${DOCKERFILE_PYTHON_VERSION}" warnings_shown=true else log_info "Custom Python version specified: ${PYTHON_VERSION} (will override base image's ${DOCKERFILE_PYTHON_VERSION})" fi fi if [[ "$warnings_shown" = true ]]; then echo log_info "💡 Tip: Remove unnecessary version parameters for faster builds that use base image versions" echo fi } # Validate prerequisites validate_prerequisites() { log_info "Validating prerequisites..." # Check if Docker is available if ! command -v docker &> /dev/null; then log_error "Docker is not installed or not in PATH" exit 1 fi # Check if gcloud is available if ! command -v gcloud &> /dev/null; then log_error "gcloud CLI is not installed or not in PATH" exit 1 fi # Check if Dockerfile exists if [[ ! -f "${SCRIPT_DIR}/Dockerfile" ]]; then log_error "Dockerfile not found in ${SCRIPT_DIR}" exit 1 fi # Check Docker daemon if ! docker info &> /dev/null; then log_error "Docker daemon is not running" exit 1 fi # Check for optional tools if ! command -v jq &> /dev/null; then log_warning "jq not found - base image update checks will be limited" fi if ! command -v curl &> /dev/null; then log_warning "curl not found - base image update checks will be limited" fi log_success "Prerequisites validated" } # Build full image name build_image_name() { FULL_IMAGE="${REGION}-docker.pkg.dev/${PROJECT}/${REPO}/${IMAGE}:${TAG}" } # Show build summary show_summary() { log_header "Docker Build Summary" echo echo "📋 Build Configuration:" echo " • Image Name: ${FULL_IMAGE}" echo " • Base Image: ${DOCKERFILE_BASE_IMAGE}$([ "$DOCKERFILE_UPDATED" = true ] && echo " (updated)" || echo "")" echo " • Platform: ${PLATFORM}" echo " • R Version: ${R_VERSION}$([ "$R_VERSION_SPECIFIED" = false ] && echo " (default)" || echo "")" echo " • Python Version: ${PYTHON_VERSION}$([ "$PYTHON_VERSION_SPECIFIED" = false ] && echo " (default)" || echo "")" echo " • Build Context: ${SCRIPT_DIR}" echo " • Use Cache: $([ "$NO_CACHE" = true ] && echo "No" || echo "Yes")" echo " • Pull Base: $([ "$PULL" = true ] && echo "Yes" || echo "No")" echo echo "🚀 Actions to be performed:" echo " 1. Configure Docker authentication for Artifact Registry" echo " 2. Build Docker image with specified parameters" if [[ "$SKIP_PUSH" = false ]]; then echo " 3. Push image to Google Artifact Registry" else echo " 3. Skip pushing to registry (--skip-push specified)" fi echo } # Confirm before proceeding confirm_action() { if [[ "$SKIP_CONFIRMATION" = true ]]; then return 0 fi echo -n "Do you want to proceed with the build? [y/N]: " read -r response case "$response" in [yY][eE][sS]|[yY]) return 0 ;; *) log_info "Operation cancelled by user" exit 0 ;; esac } # Configure Docker authentication to GCP Artifact Registry (GAR) # If this fails, most likely just need to run `gcloud auth login` or `glcoud auth application-default login` -- not sure which configure_docker_auth() { log_info "Configuring Docker for Artifact Registry..." if ! gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet; then log_error "Failed to configure Docker authentication" exit 1 fi log_success "Docker authentication configured" } # Build Docker image build_image() { log_info "Building image ${FULL_IMAGE}..." # Build docker command local docker_cmd=( "docker" "build" "--platform=${PLATFORM}" "--provenance=false" # disable attestations / extra images in deployment "--build-arg" "R_VERSION=${R_VERSION}" # the Dockerfile refers to this parameter when installing/updating R pkgs "--build-arg" "PYTHON_VERSION=${PYTHON_VERSION}" # the Dockerfile doesn't touch this but I left it anyway "-t" "${FULL_IMAGE}" ) if [[ "$NO_CACHE" = true ]]; then docker_cmd+=("--no-cache") fi if [[ "$PULL" = true ]]; then docker_cmd+=("--pull") fi docker_cmd+=("${SCRIPT_DIR}") # Execute build command if ! "${docker_cmd[@]}"; then log_error "Docker build failed" exit 1 fi log_success "Image built successfully" } # Push image to registry push_image() { if [[ "$SKIP_PUSH" = true ]]; then log_info "Skipping image push (--skip-push specified)" return 0 fi log_info "Pushing image to Artifact Registry..." if ! docker push "${FULL_IMAGE}"; then log_error "Failed to push image to registry" exit 1 fi log_success "Image pushed successfully" } # Enhanced base image update checking with version parsing check_base_image_updates() { if [[ -z "$DOCKERFILE_BASE_IMAGE" ]]; then log_warning "Could not determine base image from Dockerfile" return 0 fi log_info "Current base image: ${DOCKERFILE_BASE_IMAGE}" log_info " • Current R version: ${DOCKERFILE_R_VERSION}" log_info " • Current Python version: ${DOCKERFILE_PYTHON_VERSION}" echo # Ask user if they want to check for newer versions if [[ "$AUTO_UPDATE" = false && "$SKIP_CONFIRMATION" = false ]]; then echo -n "Would you like to check for newer versions of the base image? [y/N]: " read -r response case "$response" in [yY][eE][sS]|[yY]) check_available_versions ;; *) log_info "Skipping version check - continuing with current base image" return 0 ;; esac elif [[ "$AUTO_UPDATE" = true ]]; then log_info "Auto-update enabled - checking for newer versions..." check_available_versions fi } # Function to check and list available versions check_available_versions() { local image_name tag_part if [[ "$DOCKERFILE_BASE_IMAGE" == *":"* ]]; then image_name="${DOCKERFILE_BASE_IMAGE%:*}" tag_part="${DOCKERFILE_BASE_IMAGE##*:}" else image_name="$DOCKERFILE_BASE_IMAGE" tag_part="latest" fi log_info "Checking available versions for ${image_name}..." # Method 1: Try using Docker Hub API for rstudio images if [[ "$image_name" == "rstudio/workbench-session" ]]; then check_rstudio_versions "$image_name" "$tag_part" else # Method 2: Try using skopeo for other registries check_versions_with_skopeo "$image_name" "$tag_part" fi } # Check rstudio/workbench-session versions using Docker Hub API check_rstudio_versions() { local image_name="$1" local current_tag="$2" log_info "Querying Docker Hub for ${image_name} tags..." # Use Docker Hub API to get tags local api_url="https://hub.docker.com/v2/repositories/${image_name}/tags/?page_size=100" local available_tags if command -v curl &> /dev/null && command -v jq &> /dev/null; then available_tags=$(curl -s "$api_url" | jq -r '.results[].name' 2>/dev/null) if [[ $? -eq 0 && -n "$available_tags" ]]; then # Filter for ubuntu2204 tags and extract version info local ubuntu_tags=$(echo "$available_tags" | grep "ubuntu2204-r" | sort -V) if [[ -n "$ubuntu_tags" ]]; then log_success "Found available versions:" echo local current_found=false local newer_versions=() local current_r_version current_python_version # Parse current versions for comparison if [[ "$current_tag" =~ r([0-9]+\.[0-9]+\.[0-9]+)_ ]]; then current_r_version="${BASH_REMATCH[1]}" fi if [[ "$current_tag" =~ py([0-9]+\.[0-9]+\.[0-9]+)_ ]]; then current_python_version="${BASH_REMATCH[1]}" fi echo "Available tags (showing last 10):" echo "$ubuntu_tags" | tail -10 | while read -r tag; do local tag_r_version tag_python_version # Parse versions from tag if [[ "$tag" =~ r([0-9]+\.[0-9]+\.[0-9]+)_ ]]; then tag_r_version="${BASH_REMATCH[1]}" fi if [[ "$tag" =~ py([0-9]+\.[0-9]+\.[0-9]+)_ ]]; then tag_python_version="${BASH_REMATCH[1]}" fi # Mark current version and potential newer versions if [[ "$tag" == "$current_tag" ]]; then echo " ${tag} (CURRENT) - R:${tag_r_version}, Python:${tag_python_version}" current_found=true else echo " ${tag} - R:${tag_r_version}, Python:${tag_python_version}" # Simple version comparison (newer if R or Python version is higher) if [[ -n "$tag_r_version" && -n "$current_r_version" ]] && version_greater_than "$tag_r_version" "$current_r_version"; then newer_versions+=("$tag") elif [[ -n "$tag_python_version" && -n "$current_python_version" ]] && version_greater_than "$tag_python_version" "$current_python_version"; then newer_versions+=("$tag") fi fi done echo # Show newer versions if found if [[ ${#newer_versions[@]} -gt 0 ]]; then log_update "Potentially newer versions detected:" for version in "${newer_versions[@]}"; do echo " • $version" done echo prompt_for_update "$image_name" else log_success "Your current version appears to be up to date!" fi else log_warning "No ubuntu2204 tags found" fallback_version_check "$image_name" fi else log_warning "Failed to query Docker Hub API" fallback_version_check "$image_name" fi else log_warning "curl or jq not available for API queries" fallback_version_check "$image_name" fi } # Check versions using skopeo (for non-Docker Hub registries) check_versions_with_skopeo() { local image_name="$1" local current_tag="$2" if command -v skopeo &> /dev/null && command -v jq &> /dev/null; then log_info "Using skopeo to check available tags..." local available_tags available_tags=$(skopeo list-tags "docker://${image_name}" 2>/dev/null | jq -r '.Tags[]' 2>/dev/null) if [[ $? -eq 0 && -n "$available_tags" ]]; then echo "Available tags:" echo "$available_tags" | sort -V | tail -10 echo prompt_for_update "$image_name" else log_warning "Failed to query registry with skopeo" fallback_version_check "$image_name" fi else log_warning "skopeo or jq not available" fallback_version_check "$image_name" fi } # Fallback when automatic checking fails fallback_version_check() { local image_name="$1" log_info "💡 To manually check for newer versions:" if [[ "$image_name" == "rstudio/workbench-session" ]]; then echo " Visit: https://hub.docker.com/r/rstudio/workbench-session/tags" else echo " Run: docker search ${image_name}" echo " Or visit the registry where this image is hosted" fi echo echo -n "Have you checked and found a newer version you'd like to use? [y/N]: " read -r response case "$response" in [yY][eE][sS]|[yY]) prompt_for_manual_update "$image_name" ;; *) log_info "Continuing with current base image" ;; esac } # Prompt user to select or manually enter a new version prompt_for_update() { local image_name="$1" echo -n "Would you like to update to a newer version? [y/N]: " read -r response case "$response" in [yY][eE][sS]|[yY]) prompt_for_manual_update "$image_name" ;; *) log_info "Continuing with current base image" ;; esac } # Manual update prompt prompt_for_manual_update() { local image_name="$1" echo -n "Enter the new tag (e.g., ubuntu2204-r4.4.3_4.3.3-py3.12.11_3.11.13): " read -r new_tag if [[ -n "$new_tag" ]]; then local new_image="${image_name}:${new_tag}" log_info "New base image will be: ${new_image}" echo -n "Proceed with this update? [y/N]: " read -r confirm case "$confirm" in [yY][eE][sS]|[yY]) update_dockerfile_base_image "$new_image" # Re-parse to get new versions parse_dockerfile ;; *) log_info "Update cancelled" ;; esac else log_info "No tag provided - continuing with current image" fi } # Simple version comparison function version_greater_than() { local version1="$1" local version2="$2" # Convert versions to comparable format local v1_major v1_minor v1_patch local v2_major v2_minor v2_patch IFS='.' read -r v1_major v1_minor v1_patch <<< "$version1" IFS='.' read -r v2_major v2_minor v2_patch <<< "$version2" # Compare major version if [[ "$v1_major" -gt "$v2_major" ]]; then return 0 elif [[ "$v1_major" -lt "$v2_major" ]]; then return 1 fi # Compare minor version if [[ "$v1_minor" -gt "$v2_minor" ]]; then return 0 elif [[ "$v1_minor" -lt "$v2_minor" ]]; then return 1 fi # Compare patch version if [[ "$v1_patch" -gt "$v2_patch" ]]; then return 0 else return 1 fi } # Main execution main() { log_header "Docker Build & Deploy Script" echo # Parse command line arguments parse_args "$@" # Build full image name build_image_name # Parse Dockerfile for base image and versions parse_dockerfile # Check for base image updates check_base_image_updates # Check for version warnings check_version_warnings # Validate prerequisites validate_prerequisites # Show summary and confirm show_summary confirm_action echo log_header "Starting build process..." # Execute build steps configure_docker_auth build_image push_image echo log_success "Build process completed successfully!" log_success "Image ready: ${FULL_IMAGE}" if [[ "$DOCKERFILE_UPDATED" = true ]]; then log_info "📝 Dockerfile was updated during this build" log_info " Backup saved with timestamp" fi if [[ "$SKIP_PUSH" = false ]]; then echo log_info "You can now use this image in your deployments:" echo " docker pull ${FULL_IMAGE}" echo " docker run ${FULL_IMAGE}" fi } # Run main function with all arguments main "$@"