From c0693ddbf57db665de90b3ee0aff6e727875166f Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Wed, 13 Nov 2024 16:00:55 -0600 Subject: [PATCH] Restructured Ansible functionality --- bin/spin | 10 +- lib/actions/configure.sh | 160 +++++++++------- lib/actions/deploy.sh | 260 ++++++++++++------------- lib/actions/init.sh | 13 +- lib/actions/maintain.sh | 11 +- lib/actions/provision.sh | 14 +- lib/functions.sh | 397 ++++++++++++++++++++++----------------- 7 files changed, 465 insertions(+), 400 deletions(-) diff --git a/bin/spin b/bin/spin index d4a5c247..c0b8dd78 100755 --- a/bin/spin +++ b/bin/spin @@ -16,9 +16,7 @@ export SPIN_GROUP_ID # Default Images SPIN_PHP_IMAGE=${SPIN_PHP_IMAGE:-"serversideup/php:cli"} SPIN_NODE_IMAGE=${SPIN_NODE_IMAGE:-"node:20"} -SPIN_ANSIBLE_IMAGE=${SPIN_ANSIBLE_IMAGE:-"docker.io/serversideup/ansible-core:2.17-alpine"} -SPIN_ANSIBLE_COLLECTION_NAME=${SPIN_ANSIBLE_COLLECTION_NAME:-"serversideup.spin"} -SPIN_YQ_IMAGE=${SPIN_YQ_IMAGE:-"docker.io/mikefarah/yq:4.44.2"} +SPIN_ANSIBLE_IMAGE=${SPIN_ANSIBLE_IMAGE:-"docker.io/serversideup/ansible-core:2.18-alpine"} SPIN_GH_CLI_IMAGE=${SPIN_GH_CLI_IMAGE:-"docker.io/serversideup/github-cli:alpine"} # Script Configuration @@ -26,9 +24,15 @@ SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) SPIN_HOME=$(dirname "$SCRIPT_DIR") #Assume the parent directory of this script is the home SPIN_CACHE_DIR=${SPIN_CACHE_DIR:-$SPIN_HOME/cache} SPIN_CONFIG_FILE_LOCATION=${SPIN_CONFIG_FILE_LOCATION:-"$SPIN_HOME/conf/spin.conf"} +SPIN_CI_FOLDER=${SPIN_CI_FOLDER:-".infrastructure/conf/ci"} AUTO_UPDATE_INTERVAL_IN_DAYS=${AUTO_UPDATE_INTERVAL_IN_DAYS:-14} AUTO_PULL_INTERVAL_IN_DAYS=${AUTO_PULL_INTERVAL_IN_DAYS:-1} +# Ansible Variables +SPIN_ANSIBLE_COLLECTION_NAME="${SPIN_ANSIBLE_COLLECTION_NAME:-"serversideup.spin"}" +SPIN_INVENTORY_FILE="${SPIN_INVENTORY_FILE:-"/etc/ansible/collections/ansible_collections/serversideup/spin/plugins/inventory/spin-dynamic-inventory.sh"}" +SPIN_ANSIBLE_COLLECTIONS_PATH="$SPIN_CACHE_DIR/collections" + # Import common functions source "$SPIN_HOME/lib/functions.sh" setup_color diff --git a/lib/actions/configure.sh b/lib/actions/configure.sh index 7479ad68..67cb6304 100644 --- a/lib/actions/configure.sh +++ b/lib/actions/configure.sh @@ -4,6 +4,9 @@ # Main Action Handler ################################# action_configure() { + + validate_project_setup + case "$1" in gha) shift @@ -21,6 +24,10 @@ action_configure() { # Helper Functions ################################# configure_gha() { + local deploy_public_key_content='' + local environment_file='' + + # Ensure environment is specified if [ $# -eq 0 ]; then echo "${BOLD}${RED}❌ No environment specified${RESET}" echo "Usage: spin configure gha " @@ -28,81 +35,62 @@ configure_gha() { return 1 fi - local gha_environment="$1" + # Check if GitHub CLI image exists locally + if ! docker image inspect "$SPIN_GH_CLI_IMAGE" >/dev/null 2>&1; then + echo "${BOLD}${BLUE}🐳 Pulling GitHub CLI image...${RESET}" + if ! docker pull "$SPIN_GH_CLI_IMAGE"; then + echo "${BOLD}${RED}❌ Failed to pull GitHub CLI image${RESET}" + exit 1 + fi + fi + + # Set and validate envioronment + gha_environment="$1" shift # Remove the first argument - local gha_environment_uppercase=$(echo "$gha_environment" | tr '[:lower:]' '[:upper:]') - - validate_repository_setup || exit 1 - - local environment_file + gha_environment_uppercase=$(echo "$gha_environment" | tr '[:lower:]' '[:upper:]') + validate_github_repository_setup || exit 1 environment_file=$(validate_environment_file "$gha_environment") || exit 1 + # Set ENV_BASE_64 gh_set_env --base64 --variable "${gha_environment_uppercase}_ENV_FILE_BASE64" --file "$environment_file" - configure_gha_deployment_key "$@" - configure_gha_authorized_keys - -} - -configure_gha_deployment_key() { - local inventory_file="${SPIN_INVENTORY_FILE:-"/etc/ansible/collections/ansible_collections/serversideup/spin/plugins/inventory/spin-dynamic-inventory.sh"}" - local infrastructure_folder=".infrastructure" - - if [ ! -d "$infrastructure_folder" ]; then - echo "${BOLD}${RED}❌ Infrastructure folder not found${RESET}" - echo "Please ensure you're in the root of your project." - return 1 - fi - - if [ ! -d "$infrastructure_folder/deploy-keys" ] || [ ! -f "$infrastructure_folder/deploy-keys/.gitignore" ]; then - mkdir -p "$infrastructure_folder/deploy-keys" - echo "*" > "$infrastructure_folder/deploy-keys/.gitignore" - echo "!.gitignore" >> "$infrastructure_folder/deploy-keys/.gitignore" - fi - - if [ ! -f "$infrastructure_folder/deploy-keys/id_ed25519_deploy" ]; then - echo "πŸ”‘ Generating deployment key" - ssh-keygen -t ed25519 -C "deploy-key" -f "$infrastructure_folder/deploy-keys/id_ed25519_deploy" -N "" - echo "${BOLD}${GREEN}βœ… Deployment key generated${RESET}" - else - echo "πŸ”‘ Using existing deployment key found at \"$infrastructure_folder/deploy-keys/id_ed25519_deploy\"" - fi - - # Read the public key content - local deploy_public_key - deploy_public_key=$(cat "$infrastructure_folder/deploy-keys/id_ed25519_deploy.pub") - - echo "πŸ”‘ Adding deployment key to GitHub repository" - gh_set_env --variable "SSH_DEPLOY_PRIVATE_KEY" --file "$infrastructure_folder/deploy-keys/id_ed25519_deploy" - - echo "πŸ” Adding deployment key to servers" - prepare_ansible_args "$@" - run_ansible --allow-ssh --mount-path "$(pwd)" \ - ansible-playbook serversideup.spin.update_deploy_key \ - --inventory "$inventory_file" \ - --extra-vars @./.spin.yml \ - --extra-vars "deploy_public_key='$deploy_public_key'" \ - "${SPIN_ANSIBLE_ARGS[@]}" \ - "${SPIN_UNPROCESSED_ARGS[@]}" - - echo "${BOLD}${GREEN}βœ… Deployment key added to servers${RESET}" -} - -configure_gha_authorized_keys() { - echo "πŸ”‘ Gathering authorized keys for sudo users" - local authorized_keys - authorized_keys=$(run_ansible --minimal-output --mount-path "$(pwd)" \ - ansible-playbook serversideup.spin.get_sudo_keys \ - --extra-vars @./.spin.yml \ - | sed -n 's/.*"msg": "\(.*\)".*/\1/p' \ - | sed 's/\\\\n/\n/g') # Handle the double escaped newlines - - echo "πŸ”‘ Adding authorized keys to GitHub repository" - echo "$authorized_keys" + # Ensure deployment key exists + if [ ! -f "$SPIN_CI_FOLDER/SSH_DEPLOY_PRIVATE_KEY" ]; then + echo "πŸ”‘ Generating deployment key" + ssh-keygen -t ed25519 -C "deploy-key" -f "$SPIN_CI_FOLDER/SSH_DEPLOY_PRIVATE_KEY" -N "" + echo "${BOLD}${GREEN}βœ… Deployment key generated${RESET}" + else + echo "πŸ”‘ Using existing deployment key found at \"$SPIN_CI_FOLDER/SSH_DEPLOY_PRIVATE_KEY\"" + fi - # Add the authorized keys to GitHub secrets - gh_set_env --variable "AUTHORIZED_KEYS" --value "$authorized_keys" - + deploy_public_key_content=$(cat "$SPIN_CI_FOLDER/SSH_DEPLOY_PRIVATE_KEY.pub") + + # Prepare CI variables with Ansible + echo "πŸ”‘ Preparing CI variables with Ansible" + prepare_ansible_run "$@" + run_ansible --allow-ssh --mount-path "$(pwd):/ansible" \ + ansible-playbook serversideup.spin.prepare_ci_environment \ + --inventory "$SPIN_INVENTORY_FILE" \ + --extra-vars @./.spin.yml \ + --extra-vars "spin_environment=$gha_environment" \ + --extra-vars "spin_ci_folder=$SPIN_CI_FOLDER" \ + --extra-vars "deploy_public_key='$deploy_public_key_content'" \ + "${SPIN_ANSIBLE_ARGS[@]}" \ + "${SPIN_UNPROCESSED_ARGS[@]}" + + echo "πŸ”‘ Adding GitHub Actions secrets..." + # Loop through all files in the CI folder (sorted alphabetically) + find "$SPIN_CI_FOLDER" -type f -maxdepth 1 | sort | while read -r filepath; do + file=$(basename "$filepath") + # Skip files with file extensions and .gitignore + if [[ "$file" != *.* ]]; then + # Convert filename to uppercase for secret name + secret_name=$(echo "$file" | tr '[:lower:]' '[:upper:]') + gh_set_env --variable "$secret_name" --file "$SPIN_CI_FOLDER/$file" + fi + done + + echo "${BOLD}${BLUE}πŸš€ You're now ready to push to deploy!${RESET}" } gh_set_env() { @@ -191,6 +179,38 @@ repository_exists() { git rev-parse --is-inside-work-tree >/dev/null 2>&1 } +validate_project_setup() { + + # Validate infrastructure folder is present + if [ ! -d ".infrastructure" ]; then + echo "${BOLD}${RED}❌ Infrastructure folder not found${RESET}" + echo "Please ensure you're in the root of your project." + return 1 + fi + + if [ ! -f ".spin.yml" ]; then + echo "${BOLD}${RED}❌ .spin.yml not found${RESET}" + echo "Please ensure you're in the root of your project and a .spin.yml file exists." + return 1 + fi + + if is_encrypted_with_ansible_vault ".spin.yml" && \ + [ ! -f ".vault-password" ]; then + echo "${BOLD}${RED}❌Error: .spin.yml is encrypted with Ansible Vault, but '.vault-password' file is missing.${RESET}" + echo "${BOLD}${YELLOW}Please save your vault password in '.vault-password' in your project root and try again.${RESET}" + return 1 + fi + + # Create ci folder if it doesn't exist + if [ ! -d "$SPIN_CI_FOLDER" ] || [ ! -f "$SPIN_CI_FOLDER/.gitignore" ]; then + mkdir -p "$SPIN_CI_FOLDER" + echo "*" > "$SPIN_CI_FOLDER/.gitignore" + echo "!.gitignore" >> "$SPIN_CI_FOLDER/.gitignore" + fi + + return 0 +} + validate_environment_file() { local gha_environment="$1" local env_file=".env.$gha_environment" @@ -207,7 +227,7 @@ validate_environment_file() { fi } -validate_repository_setup() { +validate_github_repository_setup() { if ! repository_exists; then echo "${BOLD}${RED}❌ Repository not detected.${RESET}" echo "Please ensure you're in the root of your project. If you need to create a repository, run \`git init\` then \`spin gh repo create\` to create one." diff --git a/lib/actions/deploy.sh b/lib/actions/deploy.sh index 535b274e..5866eec3 100755 --- a/lib/actions/deploy.sh +++ b/lib/actions/deploy.sh @@ -2,9 +2,11 @@ action_deploy() { compose_files=() deployment_environment="" + deployment_environment_uppercase="" spin_registry_name="spin-registry" env_file="" force_ansible_upgrade=false + if is_encrypted_with_ansible_vault ".spin.yml" && \ [ ! -f ".vault-password" ]; then echo "${BOLD}${RED}❌Error: .spin.yml is encrypted with Ansible Vault, but '.vault-password' file is missing.${RESET}" @@ -12,7 +14,7 @@ action_deploy() { exit 1 fi - # First, find the deployment environment + # Set deployment environment for first argument that doesn't start with a dash for arg in "$@"; do if [[ "$arg" != -* && -z "$deployment_environment" ]]; then deployment_environment="$arg" @@ -40,6 +42,9 @@ action_deploy() { echo "${BOLD}${YELLOW}Warning: Neither .env.$SPIN_DEPLOYMENT_ENVIRONMENT nor .env found. Proceeding with default values...${RESET}" fi + # Set an uppercase version of the deployment environment + deployment_environment_uppercase=$(echo "$deployment_environment" | tr '[:lower:]' '[:upper:]') + # Source the env file if it exists if [[ -n "$env_file" ]]; then set -a @@ -68,8 +73,7 @@ action_deploy() { build_platform="${SPIN_BUILD_PLATFORM:-"linux/amd64"}" image_prefix="${SPIN_BUILD_IMAGE_PREFIX:-"localhost:$registry_port"}" image_tag="${SPIN_BUILD_TAG:-"latest"}" - inventory_file="${SPIN_INVENTORY_FILE:-"/etc/ansible/collections/ansible_collections/serversideup/spin/plugins/inventory/spin-dynamic-inventory.sh"}" - ssh_port="${SPIN_SSH_PORT:-''}" + ssh_port="${SPIN_SSH_PORT:-22}" ssh_user="${SPIN_SSH_USER:-"deploy"}" spin_project_name="${SPIN_PROJECT_NAME:-"spin"}" @@ -106,114 +110,6 @@ action_deploy() { esac done - stop_registry() { - if docker ps -q -f name="$spin_registry_name" | grep -q .; then - echo "Stopping local Docker registry..." - docker stop "$spin_registry_name" >/dev/null 2>&1 - echo "Local Docker registry stopped." - fi - } - - cleanup_on_exit() { - local exit_code=$? - - if [ $exit_code -ne 0 ]; then - echo "Failure detected. Cleaning up local services..." - fi - stop_registry - cleanup_ssh_tunnel - - exit $exit_code - } - - cleanup_ssh_tunnel() { - if [ -n "$tunnel_pid" ]; then - # Check if the process is still running - if ps -p "$tunnel_pid" > /dev/null; then - echo "Stopping local SSH tunnel..." - kill "$tunnel_pid" - echo "Local SSH tunnel stopped." - fi - fi - } - - generate_md5_hashes() { - # Check if the configs key exists - if grep -q 'configs:' "$compose_file"; then - # Extract config file paths - local config_files - config_files=$(awk '/configs:/{flag=1;next}/^[^ ]/{flag=0}flag' "$compose_file" | grep 'file:' | awk '{print $2}') - - for config_file_path in $config_files; do - if [ -f "$config_file_path" ]; then - local config_md5_hash - config_md5_hash=$(get_md5_hash "$config_file_path" | awk '{ print $1 }') - config_md5_var="SPIN_MD5_HASH_$(basename "$config_file_path" | tr '[:lower:]' '[:upper:]' | tr '.' '_')" - - eval "$config_md5_var=$config_md5_hash" - export $config_md5_var - fi - done - fi - } - - deploy_docker_stack() { - local manager_host="$1" - local ssh_port="$2" - local compose_args=() - - # Set default compose files if none were provided - if [[ ${#compose_files[@]} -eq 0 ]]; then - compose_files=("docker-compose.yml" "docker-compose.prod.yml") - fi - - # Build the compose arguments - for compose_file in "${compose_files[@]}"; do - if [[ -n "$compose_file" ]]; then - # Compute MD5 hashes if necessary - generate_md5_hashes "$compose_file" - compose_args+=("--compose-file" "$compose_file") - fi - done - - local docker_host="ssh://$ssh_user@$manager_host:$ssh_port" - echo "${BOLD}${BLUE}πŸ“€ Deploying Docker stack with compose files: ${compose_files[*]} on $manager_host...${RESET}" - docker -H "$docker_host" stack deploy "${compose_args[@]}" --detach=false --prune "$spin_project_name-$deployment_environment" - if [ $? -eq 0 ]; then - echo "${BOLD}${BLUE}πŸŽ‰ Successfully deployed Docker stack on $manager_host.${RESET}" - else - echo "${BOLD}${RED}❌ Failed to deploy Docker stack on $manager_host.${RESET}" - exit 1 - fi - } - - get_ansible_hosts() { - local host_group="$1" - local output - local exit_code - local inventory_file="${SPIN_INVENTORY_FILE:-"/etc/ansible/collections/ansible_collections/serversideup/spin/plugins/inventory/spin-dynamic-inventory.sh"}" - - # Run the Ansible command to get the list of hosts and capture both output and exit code - output=$(run_ansible --mount-path "$(pwd)" \ - ansible \ - "$host_group" \ - --inventory-file "$inventory_file" \ - --module-name ping \ - --list-hosts \ - $(set_ansible_vault_args) 2>&1) - exit_code=$? - - # Check for errors or no hosts - if echo "$output" | grep -q "No hosts matched, nothing to do" || [ $exit_code -ne 0 ]; then - echo "Error: Failed to retrieve hosts for group '$host_group'." >&2 - echo "Ansible output: $output" >&2 - return 1 - fi - - # Process and return the output if successful - echo "$output" | awk 'NR>1 {gsub(/\r/,""); print $1}' - } - # Clean up services on exit trap cleanup_on_exit EXIT @@ -230,9 +126,6 @@ action_deploy() { docker run --rm -d -p "$registry_port:5000" --user "${SPIN_USER_ID}:${SPIN_GROUP_ID}" -v "$SPIN_CACHE_DIR/registry:/var/lib/registry" --name $spin_registry_name registry:2 fi - # Prepare the Ansible run - check_galaxy_pull "$force_ansible_upgrade" - # Build and push each Dockerfile for dockerfile in $dockerfiles; do # Generate variable name based on Dockerfile name @@ -258,35 +151,46 @@ action_deploy() { exit 1 fi - # Prepare SSH connection - echo "${BOLD}${BLUE}⚑️ Setting up SSH tunnel to Docker registry...${RESET}" - - if [[ -n "$ssh_port" ]]; then - if ! ssh_port=$(get_ansible_variable "ssh_port"); then - echo "${BOLD}${RED}❌ Error: Failed to get SSH port from Ansible variables.${RESET}" >&2 - exit 1 - fi - echo " ℹ️ Using SSH port: $ssh_port" - else - echo " ℹ️ Using default SSH port" - fi - - swarm_manager_group="${SPIN_SWARM_MANAGER_GROUP:-"${deployment_environment}_manager_servers"}" - echo "${BOLD}${BLUE}πŸ” Looking for swarm manager in group: $swarm_manager_group${RESET}" - - docker_swarm_manager=$(get_ansible_hosts "$swarm_manager_group" | head -n 1) + # Get deployment host information + echo "${BOLD}${BLUE}πŸ“‘ Getting deployment host information for \"$deployment_environment\"...${RESET}" + prepare_ansible_run "$@" + run_ansible --mount-path "$(pwd):/ansible" \ + ansible-playbook serversideup.spin.prepare_ci_environment \ + --inventory "$SPIN_INVENTORY_FILE" \ + --extra-vars @./.spin.yml \ + --extra-vars "spin_environment=$deployment_environment" \ + --extra-vars "spin_ci_folder=$SPIN_CI_FOLDER" \ + --tags "get-host" \ + "${SPIN_ANSIBLE_ARGS[@]}" \ + "${SPIN_UNPROCESSED_ARGS[@]}" + + docker_swarm_manager=$(cat "$SPIN_CI_FOLDER/${deployment_environment_uppercase}_SSH_REMOTE_HOST") if [ $? -ne 0 ] || [ -z "$docker_swarm_manager" ]; then - echo "${BOLD}${RED}❌ Error: Failed to get a valid swarm manager host for group '$swarm_manager_group'.${RESET}" >&2 - echo "${BOLD}${RED}Please check if the environment '$deployment_environment' exists in '$(basename "$inventory_file")'.${RESET}" >&2 + echo "${BOLD}${RED}❌ Error: Failed to get a valid swarm manager host for group '$deployment_environment'.${RESET}" >&2 exit 1 else - echo "${BOLD}${GREEN}βœ… Found swarm manager: $docker_swarm_manager${RESET}" + echo "${BOLD}${GREEN}βœ… Deploying to Swarm Manager: $docker_swarm_manager${RESET}" fi # Create SSH tunnel to Docker registry echo "${BOLD}${BLUE}πŸš‡ Creating SSH tunnel to Docker registry...${RESET}" - if ssh -f -n -N -R "${registry_port}:localhost:${registry_port}" -p "${ssh_port}" "${ssh_user}@${docker_swarm_manager}" -o ExitOnForwardFailure=yes -o ServerAliveInterval=60 -o ServerAliveCountMax=3; then + + # Build SSH command with proper quoting + ssh_cmd=( + ssh -f -n -N + -R "${registry_port}:localhost:${registry_port}" + -p "${ssh_port}" + "${ssh_user}@${docker_swarm_manager}" + -o ExitOnForwardFailure=yes + -o ServerAliveInterval=60 + -o ServerAliveCountMax=3 + -o StrictHostKeyChecking=accept-new + ) + + echo "${BOLD}${BLUE}πŸ“ Debug: Executing SSH command: ${ssh_cmd[*]}${RESET}" + + if "${ssh_cmd[@]}"; then echo "${BOLD}${GREEN}βœ… SSH tunnel created successfully${RESET}" echo "${BOLD}${BLUE}ℹ️ Tunnel details:${RESET}" echo " πŸ”— Local port: ${registry_port}" @@ -296,9 +200,10 @@ action_deploy() { echo " πŸ”’ SSH port: ${ssh_port}" echo "${BOLD}${BLUE}πŸ”„ The tunnel will forward connections from the remote port ${registry_port} to localhost:${registry_port}${RESET}" else - echo "${BOLD}${RED}❌ Failed to create SSH tunnel. Exiting...${RESET}" + ssh_exit_code=$? + echo "${BOLD}${RED}❌ Failed to create SSH tunnel (Exit code: $ssh_exit_code)${RESET}" echo "${BOLD}${YELLOW}πŸ”§ Troubleshoot your connection by running:${RESET}" - echo "${BOLD}${YELLOW}ssh -p $ssh_port $ssh_user@$docker_swarm_manager${RESET}" + echo "${BOLD}${YELLOW}ssh -v -p ${ssh_port} ${ssh_user}@${docker_swarm_manager}${RESET}" exit 1 fi @@ -310,4 +215,85 @@ action_deploy() { stop_registry cleanup_ssh_tunnel echo "${BOLD}${GREEN}βœ… Docker stack deployment completed.${RESET}" +} + +stop_registry() { + if docker ps -q -f name="$spin_registry_name" | grep -q .; then + echo "Stopping local Docker registry..." + docker stop "$spin_registry_name" >/dev/null 2>&1 + echo "Local Docker registry stopped." + fi +} + +cleanup_on_exit() { + local exit_code=$? + + if [ $exit_code -ne 0 ]; then + echo "❌ Failure detected. Cleaning up local services..." + fi + stop_registry + cleanup_ssh_tunnel + + exit $exit_code +} + +cleanup_ssh_tunnel() { + if [ -n "$tunnel_pid" ]; then + # Check if the process is still running + if ps -p "$tunnel_pid" > /dev/null; then + echo "Stopping local SSH tunnel..." + kill "$tunnel_pid" + echo "Local SSH tunnel stopped." + fi + fi +} + +generate_md5_hashes() { + # Check if the configs key exists + if grep -q 'configs:' "$compose_file"; then + # Extract config file paths + local config_files + config_files=$(awk '/configs:/{flag=1;next}/^[^ ]/{flag=0}flag' "$compose_file" | grep 'file:' | awk '{print $2}') + + for config_file_path in $config_files; do + if [ -f "$config_file_path" ]; then + local config_md5_hash + config_md5_hash=$(get_md5_hash "$config_file_path" | awk '{ print $1 }') + config_md5_var="SPIN_MD5_HASH_$(basename "$config_file_path" | tr '[:lower:]' '[:upper:]' | tr '.' '_')" + + eval "$config_md5_var=$config_md5_hash" + export $config_md5_var + fi + done + fi +} + +deploy_docker_stack() { + local manager_host="$1" + local ssh_port="$2" + local compose_args=() + + # Set default compose files if none were provided + if [[ ${#compose_files[@]} -eq 0 ]]; then + compose_files=("docker-compose.yml" "docker-compose.prod.yml") + fi + + # Build the compose arguments + for compose_file in "${compose_files[@]}"; do + if [[ -n "$compose_file" ]]; then + # Compute MD5 hashes if necessary + generate_md5_hashes "$compose_file" + compose_args+=("--compose-file" "$compose_file") + fi + done + + local docker_host="ssh://$ssh_user@$manager_host:$ssh_port" + echo "${BOLD}${BLUE}πŸ“€ Deploying Docker stack with compose files: ${compose_files[*]} on $manager_host...${RESET}" + docker -H "$docker_host" stack deploy "${compose_args[@]}" --detach=false --prune "$spin_project_name-$deployment_environment" + if [ $? -eq 0 ]; then + echo "${BOLD}${BLUE}πŸŽ‰ Successfully deployed Docker stack on $manager_host.${RESET}" + else + echo "${BOLD}${RED}❌ Failed to deploy Docker stack on $manager_host.${RESET}" + exit 1 + fi } \ No newline at end of file diff --git a/lib/actions/init.sh b/lib/actions/init.sh index 93c91921..27e39a56 100755 --- a/lib/actions/init.sh +++ b/lib/actions/init.sh @@ -55,7 +55,18 @@ action_init() { copy_template_files "$SPIN_TEMPLATE_TEMPORARY_SRC_DIR/template" "$absolute_project_directory" # Download default config and inventory from GitHub - get_file_from_github_release --repo "serversideup/ansible-collection-spin" --release-type "stable" --src ".spin.example.yml" --dest "$absolute_project_directory/.spin.yml" + if [[ "$SPIN_ANSIBLE_COLLECTION_NAME" == git+* ]]; then + # Parse git URL format: git+https://github.com/owner/repo.git,branch + local git_url="${SPIN_ANSIBLE_COLLECTION_NAME#git+}" + local repo="${git_url%%,*}" + repo="${repo#https://github.com/}" + repo="${repo%.git}" + local branch="${SPIN_ANSIBLE_COLLECTION_NAME##*,}" + + get_file_from_github_release --repo "$repo" --branch "$branch" --src ".spin.example.yml" --dest "$absolute_project_directory/.spin.yml" + else + get_file_from_github_release --repo "serversideup/ansible-collection-spin" --release-type "stable" --src ".spin.example.yml" --dest "$absolute_project_directory/.spin.yml" + fi # Check if the template has a post-install script and execute it if [ -f "$SPIN_TEMPLATE_TEMPORARY_SRC_DIR/post-install.sh" ]; then diff --git a/lib/actions/maintain.sh b/lib/actions/maintain.sh index c897bf32..a109368e 100644 --- a/lib/actions/maintain.sh +++ b/lib/actions/maintain.sh @@ -1,12 +1,9 @@ #!/usr/bin/env bash -action_maintain(){ - local inventory_file="${SPIN_INVENTORY_FILE:-"/etc/ansible/collections/ansible_collections/serversideup/spin/plugins/inventory/spin-dynamic-inventory.sh"}" - - prepare_ansible_args "$@" - - run_ansible --allow-ssh --mount-path "$(pwd)" \ +action_maintain(){ + prepare_ansible_run "$@" + run_ansible --allow-ssh --mount-path "$(pwd):/ansible" \ ansible-playbook serversideup.spin.maintain \ - --inventory "$inventory_file" \ + --inventory "$SPIN_INVENTORY_FILE" \ --extra-vars @./.spin.yml \ "${SPIN_ANSIBLE_ARGS[@]}" \ "${SPIN_UNPROCESSED_ARGS[@]}" diff --git a/lib/actions/provision.sh b/lib/actions/provision.sh index da480a12..85019c4a 100644 --- a/lib/actions/provision.sh +++ b/lib/actions/provision.sh @@ -1,12 +1,16 @@ #!/usr/bin/env bash action_provision(){ - local inventory_file="${SPIN_INVENTORY_FILE:-"/etc/ansible/collections/ansible_collections/serversideup/spin/plugins/inventory/spin-dynamic-inventory.sh"}" + if is_encrypted_with_ansible_vault ".spin.yml" && \ + [ ! -f ".vault-password" ]; then + echo "${BOLD}${RED}❌Error: .spin.yml is encrypted with Ansible Vault, but '.vault-password' file is missing.${RESET}" + echo "${BOLD}${YELLOW}Please save your vault password in '.vault-password' in your project root and try again.${RESET}" + exit 1 + fi - prepare_ansible_args "$@" - - run_ansible --allow-ssh --mount-path "$(pwd)" \ + prepare_ansible_run "$@" + run_ansible --set-env --allow-ssh --mount-path "$(pwd):/ansible" \ ansible-playbook serversideup.spin.provision \ - --inventory "$inventory_file" \ + --inventory "$SPIN_INVENTORY_FILE" \ --extra-vars @./.spin.yml \ "${SPIN_ANSIBLE_ARGS[@]}" \ "${SPIN_UNPROCESSED_ARGS[@]}" diff --git a/lib/functions.sh b/lib/functions.sh index 6dad33ff..6156be59 100755 --- a/lib/functions.sh +++ b/lib/functions.sh @@ -47,15 +47,6 @@ check_connection_with_cmd() { esac } -check_galaxy_pull(){ - local force_ansible_upgrade="$1" - if [[ $(needs_update ".spin-ansible-collection-pull" "1") || "$force_ansible_upgrade" == true ]]; then - run_ansible --allow-ssh --mount-path "$(pwd)" \ - ansible-galaxy collection install "${SPIN_ANSIBLE_COLLECTION_NAME}" --upgrade - save_current_time_to_cache_file ".spin-ansible-collection-pull" - fi -} - check_for_upgrade() { if needs_update ".spin-last-update" "$AUTO_UPDATE_INTERVAL_IN_DAYS" || [ "$1" == "--force" ]; then if [ "$1" != "--force" ]; then @@ -246,7 +237,7 @@ download_spin_template_repository() { case "$1" in -b|--branch) branch="$2" - shift 2 # Shift both the flag and its value + shift 2 ;; -l|--local) SPIN_TEMPLATE_TEMPORARY_SRC_DIR="$2" @@ -462,62 +453,39 @@ filter_out_spin_arguments() { get_ansible_variable(){ local variable_name="$1" - local file="${2:-".spin.yml"}" - local vault_args=() local raw_output='' - local cleaned_output='' + local clean_output='' - # Check if the file is encrypted and .vault-password is missing - if is_encrypted_with_ansible_vault "$file"; then - if [ ! -f ".vault-password" ]; then - echo "${BOLD}${RED}❌Error: $file is encrypted with Ansible Vault, but '.vault-password' file is missing.${RESET}" >&2 - echo "${BOLD}${YELLOW}Please save your vault password in '.vault-password' in your project root and try again.${RESET}" >&2 - return 1 - fi - - vault_args+=("--vault-password-file" ".vault-password") - - raw_output=$(run_ansible --mount-path "$(pwd)" \ - ansible localhost -m debug \ - -a "var=${variable_name}" \ - -e "@${file}" \ - "${vault_args[@]}" 2>&1) + if [[ -z "$variable_name" ]]; then + echo "${BOLD}${RED}❌ No variable name specified.${RESET}" >&2 + return 1 + fi - # Check for errors in the output - if echo "$raw_output" | grep -q "ERROR!"; then - echo "${BOLD}${RED}Error: Failed to retrieve variable. Details:${RESET}" >&2 - echo "$raw_output" >&2 + # Run ansible command and capture output + raw_output=$(docker run --rm \ + -e "PUID=${SPIN_USER_ID}" \ + -e "PGID=${SPIN_GROUP_ID}" \ + -e "RUN_AS_USER=$(whoami)" \ + -e "ANSIBLE_STDOUT_CALLBACK=minimal" \ + -e "ANSIBLE_DISPLAY_SKIPPED_HOSTS=false" \ + -e "ANSIBLE_DISPLAY_OK_HOSTS=false" \ + -v "$(pwd):/ansible" \ + -v "$SPIN_ANSIBLE_COLLECTIONS_PATH:/etc/ansible/collections" \ + "$SPIN_ANSIBLE_IMAGE" \ + ansible-playbook serversideup.spin.get_variable \ + --inventory "$SPIN_INVENTORY_FILE" \ + --extra-vars @./.spin.yml \ + --extra-vars "variable_name=$variable_name" 2>&1) || { + echo "${BOLD}${RED}❌ Failed to get ansible variable: $variable_name${RESET}" >&2 + echo "${BOLD}${RED}Error: $raw_output${RESET}" >&2 return 1 - fi + } - # Check for variable presence - if echo "$raw_output" | grep -q "${variable_name}"; then - cleaned_output=$(echo "$raw_output" | awk -F': ' '/"'"$variable_name"'"/ {print $2}' | sed 's/^"\(.*\)"$/\1/' | sed "s/^'\(.*\)'$/\1/" | sed 's/\x1b\[[0-9;]*m//g') - else - echo "${BOLD}${YELLOW}Warning: Variable ${variable_name} not found in $file${RESET}" >&2 - return 1 - fi - else - cleaned_output=$(docker run --rm -i --user "${SPIN_USER_ID}:${SPIN_GROUP_ID}" -v "$(pwd)":/workdir -w /workdir "$SPIN_YQ_IMAGE" eval ".$variable_name" "$file") - fi + # Extract just the value from the msg field + clean_output=$(echo "$raw_output" | grep -o '"msg": .*' | sed 's/"msg": //;s/^"//;s/"$//') - # Handle different variable types - case "$cleaned_output" in - true|false) - echo "$cleaned_output" - ;; - [0-9]*) - echo "$cleaned_output" - ;; - *) - # For strings, re-add quotes if they were present in the original - if [[ "$raw_output" == *\"$cleaned_output\"* || "$raw_output" == *\'$cleaned_output\'* ]]; then - echo "\"$cleaned_output\"" - else - echo "$cleaned_output" - fi - ;; - esac + # Return the cleaned output + echo "$clean_output" } get_github_release() { @@ -534,24 +502,30 @@ get_github_release() { } get_file_from_github_release() { + local source_type="release" # Default to release downloads while [[ "$#" -gt 0 ]]; do case "$1" in -r|--repo) local repo="$2" - shift 2 # Shift both the flag and its value + shift 2 ;; -t|--release-type) local release_type="$2" - shift 2 # Shift both the flag and its value + shift 2 + ;; + -b|--branch) + local branch="$2" + source_type="branch" + shift 2 ;; -s|--src) local source_file="$2" - shift 2 # Shift both the flag and its value + shift 2 ;; -d|--dest) local destination_file="$2" - shift 2 # Shift both the flag and its value + shift 2 ;; *) echo "${BOLD}${RED}πŸ›‘ Unsupported flag ${1}.${RESET}" @@ -563,12 +537,20 @@ get_file_from_github_release() { destination_filename=$(basename "$destination_file") if [[ -f "$destination_file" ]]; then - trap show_existing_files_warning EXIT - echo "πŸ‘‰ ${MAGENTA}\"$destination_filename\" already exists. Skipping...${RESET}" - return 0 + trap show_existing_files_warning EXIT + echo "πŸ‘‰ ${MAGENTA}\"$destination_filename\" already exists. Skipping...${RESET}" + return 0 + fi + + # Construct the URL based on source type + local download_url + if [[ "$source_type" == "branch" ]]; then + download_url="https://raw.githubusercontent.com/$repo/$branch/$source_file" + else + download_url="https://raw.githubusercontent.com/$repo/$(get_github_release "$release_type" "$repo")/$source_file" fi - curl --silent --location --output "$destination_file" "https://raw.githubusercontent.com/$repo/$(get_github_release "$release_type" "$repo")/$source_file" + curl --silent --location --output "$destination_file" "$download_url" echo "βœ… \"$destination_filename\" has been created." } @@ -821,13 +803,80 @@ needs_update() { # Calculate the threshold time for update local threshold_time=$(current_time_minus "$interval") - if (( threshold_time <= last_update_time )); then - return 1 + if (( last_update_time >= threshold_time )); then + return 1 # No update needed - last update is newer than threshold else - return 0 + return 0 # Update needed - last update is older than threshold fi } +prepare_ansible_run() { + # Return values will be stored in these global variables + SPIN_ANSIBLE_ARGS=() + SPIN_UNPROCESSED_ARGS=() + SPIN_REMOTE_USER="$USER" + SPIN_FORCE_INSTALL_GALAXY_DEPS=false + + # Process arguments + while [[ "$#" -gt 0 ]]; do + case "$1" in + --host|-h) + SPIN_ANSIBLE_ARGS+=("--extra-vars" "target=$2") + shift 2 + ;; + --user|-u) + SPIN_REMOTE_USER="$2" + shift 2 + ;; + --port|-p) + SPIN_ANSIBLE_ARGS+=("--extra-vars" "ansible_port=$2") + shift 2 + ;; + --upgrade|-U) + SPIN_FORCE_INSTALL_GALAXY_DEPS=true + shift + ;; + *) + SPIN_UNPROCESSED_ARGS+=("$1") + shift + ;; + esac + done + + # Create the collections directory if it doesn't exist + if [[ ! -d "$SPIN_ANSIBLE_COLLECTIONS_PATH" ]]; then + mkdir -p "$SPIN_ANSIBLE_COLLECTIONS_PATH" + fi + + # Install Ansible Galaxy dependencies if the flag is set + if [[ $(needs_update ".spin-ansible-collection-pull" "1") || "$SPIN_FORCE_INSTALL_GALAXY_DEPS" == true ]]; then + echo "Installing Ansible Galaxy dependencies..." + docker run --rm -it \ + -e "PUID=${SPIN_USER_ID}" \ + -e "PGID=${SPIN_GROUP_ID}" \ + -e "RUN_AS_USER=$(whoami)" \ + -v "$SPIN_ANSIBLE_COLLECTIONS_PATH:/etc/ansible/collections" \ + "$SPIN_ANSIBLE_IMAGE" \ + ansible-galaxy collection install "${SPIN_ANSIBLE_COLLECTION_NAME}" --force + save_current_time_to_cache_file ".spin-ansible-collection-pull" + fi + + # Set remote Ansible user + SPIN_ANSIBLE_ARGS+=("--extra-vars" "spin_remote_user=$SPIN_REMOTE_USER") + + # Set the --ask-become-pass flag if passwordless sudo is not enabled + local use_passwordless_sudo + use_passwordless_sudo=$(get_ansible_variable "use_passwordless_sudo") + use_passwordless_sudo=${use_passwordless_sudo:-"true"} + if [ "$SPIN_REMOTE_USER" != "root" ] && [ "$use_passwordless_sudo" = 'false' ]; then + SPIN_ANSIBLE_ARGS+=("--ask-become-pass") + fi + + # Append vault args to additional ansible args + IFS=' ' read -r -a vault_args < <(set_ansible_vault_args) + SPIN_ANSIBLE_ARGS+=("${vault_args[@]}") +} + print_version() { # Use the local Git repo to show our version printf "${BOLD}${YELLOW}Spin Version:${RESET} \n" @@ -1042,11 +1091,19 @@ prompt_to_encrypt_files(){ run_ansible() { local additional_docker_args=() local args_without_options=() - ansible_collections_path="$SPIN_CACHE_DIR/collections" - - # Create the collections directory if it doesn't exist - mkdir -p "$ansible_collections_path" - additional_docker_args+=("-v" "$ansible_collections_path:/etc/ansible/collections") + local allow_ssh=false + local minimal_output=false + local set_env=false + SPIN_FORCE_INSTALL_GALAXY_DEPS=${SPIN_FORCE_INSTALL_GALAXY_DEPS:-false} + + # List of environment variables that can be forwarded to Ansible container + # Only add variables that are required for cloud provider authentication + # Format: PROVIDER_TOKEN_NAME + local env_vars_to_forward=( + "HCLOUD_TOKEN" + "DO_API_TOKEN" + "VULTR_API_KEY" + ) # Create the known_hosts file if it doesn't exist if [[ ! -f "$HOME/.ssh/known_hosts" ]]; then @@ -1056,40 +1113,80 @@ run_ansible() { while [[ "$#" -gt 0 ]]; do case "$1" in --allow-ssh) - additional_docker_args+=("-v" "$HOME/.ssh/:/ssh/:ro" "-v" "$HOME/.ssh/known_hosts:/ssh/known_hosts:rw") - # Mount the SSH Agent for macOS and Linux (including WSL2) systems - if [ -n "$SSH_AUTH_SOCK" ]; then - case "$(uname -s)" in - Darwin) - # macOS - additional_docker_args+=("-v" "/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock" "-e" "SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock") - ;; - Linux) - # Linux (including WSL2) - additional_docker_args+=("-v" "$SSH_AUTH_SOCK:$SSH_AUTH_SOCK" "-e" "SSH_AUTH_SOCK=$SSH_AUTH_SOCK") - ;; - esac - fi + allow_ssh=true shift ;; --mount-path) - additional_docker_args+=("-v" "${2}:/ansible") + additional_docker_args+=("-v" "${2}") shift 2 ;; --minimal-output) - additional_docker_args+=( - -e "ANSIBLE_STDOUT_CALLBACK=minimal" - -e "ANSIBLE_DISPLAY_SKIPPED_HOSTS=false" - -e "ANSIBLE_DISPLAY_OK_HOSTS=false" - ) + minimal_output=true shift ;; + --set-env) + set_env=true + shift + ;; + --container-env) + additional_docker_args+=("-e" "$2") + shift 2 + ;; *) args_without_options+=("$1") shift ;; esac done + + if [[ "$allow_ssh" == true ]]; then + additional_docker_args+=("-v" "$HOME/.ssh/:/ssh/:ro" "-v" "$HOME/.ssh/known_hosts:/ssh/known_hosts:rw") + + # Mount the SSH Agent for macOS and Linux (including WSL2) systems + if [ -n "$SSH_AUTH_SOCK" ]; then + case "$(uname -s)" in + Darwin) + # macOS + additional_docker_args+=("-v" "/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock" "-e" "SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock") + ;; + Linux) + # Linux (including WSL2) + additional_docker_args+=("-v" "$SSH_AUTH_SOCK:$SSH_AUTH_SOCK" "-e" "SSH_AUTH_SOCK=$SSH_AUTH_SOCK") + ;; + esac + fi + fi + + if [[ "$minimal_output" == true ]]; then + additional_docker_args+=( + -e "ANSIBLE_STDOUT_CALLBACK=minimal" + -e "ANSIBLE_DISPLAY_SKIPPED_HOSTS=false" + -e "ANSIBLE_DISPLAY_OK_HOSTS=false" + ) + fi + + if [[ "$set_env" == true ]]; then + additional_docker_args+=("-e" "ANSIBLE_FORCE_COLOR=1") + + # Forward specific environment variables if they exist + for env_var in "${env_vars_to_forward[@]}"; do + if [[ -n "${!env_var}" ]]; then + # Environment variable exists in current shell + additional_docker_args+=("-e" "${env_var}=${!env_var}") + elif [[ -f ".env" ]] && grep -q "^${env_var}=" ".env"; then + # Variable exists in .env file + local env_value + env_value=$(grep "^${env_var}=" ".env" | cut -d '=' -f2- | tr -d '"' | tr -d "'") + additional_docker_args+=("-e" "${env_var}=${env_value}") + fi + done + fi + + # Mount collections directory if it exists + if [[ -d "$SPIN_ANSIBLE_COLLECTIONS_PATH" ]]; then + additional_docker_args+=("-v" "$SPIN_ANSIBLE_COLLECTIONS_PATH:/etc/ansible/collections") + fi + docker run --rm -it \ -e "PUID=${SPIN_USER_ID}" \ -e "PGID=${SPIN_GROUP_ID}" \ @@ -1110,8 +1207,10 @@ run_gh() { # Fall back to Docker implementation if gh is not installed local additional_docker_args=() local gh_command=("$@") + local interactive_flag="" local use_tty=false - local interactive_commands=( + local use_interactive=false + local interactive_tty_commands=( "auth login" "auth refresh" "issue create" "issue edit" "pr create" "pr edit" "pr review" @@ -1119,11 +1218,17 @@ run_gh() { "gist create" "gist edit" ) + # Check if there's data being piped in + if [ ! -t 0 ]; then + use_interactive=true # Always use -i when receiving STDIN + fi + # Check if the command needs interactive TTY local cmd_string="${gh_command[*]}" - for interactive_cmd in "${interactive_commands[@]}"; do + for interactive_cmd in "${interactive_tty_commands[@]}"; do if [[ "$cmd_string" == "$interactive_cmd"* ]]; then use_tty=true + use_interactive=true break fi done @@ -1151,27 +1256,24 @@ run_gh() { ;; esac fi - - # Run GH CLI via Docker - if [ "$use_tty" = true ]; then - docker run --rm -it \ - -e "PUID=${SPIN_USER_ID}" \ - -e "PGID=${SPIN_GROUP_ID}" \ - -e "RUN_AS_USER=$(whoami)" \ - -v "$(pwd):/workdir" \ - -v "$HOME/.config/gh:/config/gh:rw" \ - "${additional_docker_args[@]}" \ - "$SPIN_GH_CLI_IMAGE" gh "${gh_command[@]}" - else - docker run --rm \ - -e "PUID=${SPIN_USER_ID}" \ - -e "PGID=${SPIN_GROUP_ID}" \ - -e "RUN_AS_USER=$(whoami)" \ - -v "$(pwd):/workdir" \ - -v "$HOME/.config/gh:/config/gh:rw" \ - "${additional_docker_args[@]}" \ - "$SPIN_GH_CLI_IMAGE" gh "${gh_command[@]}" + + # Determine interactive/TTY flags + if [ "$use_tty" = true ] && [ "$use_interactive" = true ]; then + interactive_flag="-it" + elif [ "$use_tty" = true ]; then + interactive_flag="-t" + elif [ "$use_interactive" = true ]; then + interactive_flag="-i" fi + + docker run --rm $interactive_flag \ + -e "PUID=${SPIN_USER_ID}" \ + -e "PGID=${SPIN_GROUP_ID}" \ + -e "RUN_AS_USER=$(whoami)" \ + -v "$(pwd):/workdir" \ + -v "$HOME/.config/gh:/config/gh:rw" \ + "${additional_docker_args[@]}" \ + "$SPIN_GH_CLI_IMAGE" gh "${gh_command[@]}" } save_current_time_to_cache_file() { @@ -1265,63 +1367,4 @@ update_last_pull_timestamp() { # Replace the original .spin-last-pull file with the updated temporary file mv "$file.tmp" "$file" -} - -prepare_ansible_args() { - # Return values will be stored in these global variables - SPIN_ANSIBLE_ARGS=() - SPIN_UNPROCESSED_ARGS=() - SPIN_REMOTE_USER="$USER" - SPIN_FORCE_UPGRADE=false - - # Process arguments - while [[ "$#" -gt 0 ]]; do - case "$1" in - --host|-h) - SPIN_ANSIBLE_ARGS+=("--extra-vars" "target=$2") - shift 2 - ;; - --user|-u) - SPIN_REMOTE_USER="$2" - shift 2 - ;; - --port|-p) - SPIN_ANSIBLE_ARGS+=("--extra-vars" "ansible_port=$2") - shift 2 - ;; - --upgrade|-U) - SPIN_FORCE_UPGRADE=true - shift - ;; - *) - SPIN_UNPROCESSED_ARGS+=("$1") - shift - ;; - esac - done - - # Set Ansible User - SPIN_ANSIBLE_ARGS+=("--extra-vars" "spin_remote_user=$SPIN_REMOTE_USER") - local use_passwordless_sudo - if ! use_passwordless_sudo=$(get_ansible_variable "use_passwordless_sudo"); then - echo "${BOLD}${RED}❌ Error: Failed to get ansible variable.${RESET}" >&2 - return 1 - fi - use_passwordless_sudo=${use_passwordless_sudo:-"false"} - if [ "$SPIN_REMOTE_USER" != "root" ] && [ "$use_passwordless_sudo" = 'false' ]; then - SPIN_ANSIBLE_ARGS+=("--ask-become-pass") - fi - - # Append vault args to additional ansible args - IFS=' ' read -r -a vault_args < <(set_ansible_vault_args) - SPIN_ANSIBLE_ARGS+=("${vault_args[@]}") - - # Check Docker image - echo "Starting Ansible..." - if ! docker image inspect "${SPIN_ANSIBLE_IMAGE}" &> /dev/null; then - echo "Docker image ${SPIN_ANSIBLE_IMAGE} not found. Pulling..." - docker pull "${SPIN_ANSIBLE_IMAGE}" - fi - - check_galaxy_pull "$SPIN_FORCE_UPGRADE" } \ No newline at end of file