#!/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 "$@"