diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 948a14d049..130fce817d 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -3,9 +3,12 @@ set -eo pipefail # logging functions mysql_log() { - local type="$1"; shift - local text="$*"; if [ "$#" -eq 0 ]; then text="$(cat)"; fi - local dt; dt="$(date --rfc-3339=seconds)" + local type="$1" + shift + local text="$*" + if [ "$#" -eq 0 ]; then text="$(cat)"; fi + local dt + dt="$(date --rfc-3339=seconds)" local color_reset="\033[0m" local color case "$type" in @@ -16,24 +19,35 @@ mysql_log() { printf '%b%s [%s] [Entrypoint]: %s%b\n' "$color" "$dt" "$type" "$text" "$color_reset" } mysql_note() { - mysql_log Note "$@" + mysql_log Note "$@" } mysql_warn() { - mysql_log Warn "$@" >&2 + mysql_log Warn "$@" >&2 } mysql_error() { - mysql_log ERROR "$@" >&2 - if [ -f "/tmp/server.log" ]; then - mysql_note "Server log:" - cat /tmp/server.log >&2 - fi - mysql_note "Remove this container with 'docker rm -f ' before retrying" - exit 1 + mysql_log ERROR "$@" >&2 + if [ -f "/tmp/server.log" ]; then + mysql_note "Server log:" + cat /tmp/server.log >&2 + fi + mysql_note "Remove this container with 'docker rm -f ' before retrying" + exit 1 } docker_process_sql() { dolt sql } +# Helper function to execute SQL with error capture +execute_sql_with_error_capture() { + local sql_command="$1" + local error_message="$2" + local output + + if ! output=$(dolt sql -q "$sql_command" 2>&1); then + mysql_error "$error_message Error output: $output" + fi +} + CONTAINER_DATA_DIR="/var/lib/dolt" INIT_COMPLETED="$CONTAINER_DATA_DIR/.init_completed" DOLT_CONFIG_DIR="/etc/dolt/doltcfg.d" @@ -41,60 +55,62 @@ SERVER_CONFIG_DIR="/etc/dolt/servercfg.d" DOLT_ROOT_PATH="/.dolt" check_for_dolt() { - local dolt_bin=$(which dolt) - if [ ! -x "$dolt_bin" ]; then - mysql_error "dolt binary executable not found" - fi + local dolt_bin=$(which dolt) + if [ ! -x "$dolt_bin" ]; then + mysql_error "dolt binary executable not found" + fi } get_env_var() { - local var_name="$1" - local mysql_var="MYSQL_${var_name}" - local dolt_var="DOLT_${var_name}" + local var_name="$1" + local mysql_var="MYSQL_${var_name}" + local dolt_var="DOLT_${var_name}" - if [ -n "${!mysql_var}" ]; then - echo "${!mysql_var}" - elif [ -n "${!dolt_var}" ]; then - echo "${!dolt_var}" - else - echo "" - fi + if [ -n "${!mysql_var}" ]; then + echo "${!mysql_var}" + elif [ -n "${!dolt_var}" ]; then + echo "${!dolt_var}" + else + echo "" + fi } get_env_var_name() { - local var_name="$1" - local mysql_var="MYSQL_${var_name}" - local dolt_var="DOLT_${var_name}" + local var_name="$1" + local mysql_var="MYSQL_${var_name}" + local dolt_var="DOLT_${var_name}" - if [ -n "${!mysql_var}" ]; then - echo "MYSQL_${var_name}" - elif [ -n "${!dolt_var}" ]; then - echo "DOLT_${var_name}" - else - echo "MYSQL_${var_name}/DOLT_${var_name}" - fi + if [ -n "${!mysql_var}" ]; then + echo "MYSQL_${var_name}" + elif [ -n "${!dolt_var}" ]; then + echo "DOLT_${var_name}" + else + echo "MYSQL_${var_name}/DOLT_${var_name}" + fi } # arg $1 is the directory to search in # arg $2 is the type file to search for +# Returns the config file path via stdout, empty if none found get_config_file_path_if_exists() { - CONFIG_PROVIDED= - local CONFIG_DIR=$1 - local FILE_TYPE=$2 - if [ -d "$CONFIG_DIR" ]; then - mysql_note "Checking for config provided in $CONFIG_DIR" - local number_of_files_found=$(find "$CONFIG_DIR" -type f -name "*.$FILE_TYPE" | wc -l) - if [ "$number_of_files_found" -gt 1 ]; then - CONFIG_PROVIDED= - mysql_warn "multiple config file found in $CONFIG_DIR, using default config" - elif [ "$number_of_files_found" -eq 1 ]; then - local files_found=$(ls "$CONFIG_DIR"/*"$FILE_TYPE") - mysql_note "$files_found file is found" - CONFIG_PROVIDED=$files_found - else - CONFIG_PROVIDED= - fi - fi + local config_dir="$1" + local file_type="$2" + + if [ ! -d "$config_dir" ]; then + return + fi + + mysql_note "Checking for config provided in $config_dir" + local number_of_files_found=$(find "$config_dir" -type f -name "*.$file_type" | wc -l) + + if [ "$number_of_files_found" -gt 1 ]; then + mysql_warn "multiple config file found in $config_dir, using default config" + return + elif [ "$number_of_files_found" -eq 1 ]; then + local files_found=$(ls "$config_dir"/*"$file_type") + mysql_note "$files_found file is found" + echo "$files_found" + fi } # taken from https://github.com/docker-library/mysql/blob/master/8.0/docker-entrypoint.sh @@ -103,198 +119,214 @@ get_config_file_path_if_exists() { # ie: docker_process_init_files /always-initdb.d/* # process initializer files, based on file extensions docker_process_init_files() { - mysql_note "Running init scripts" - local f - for f; do - case "$f" in - *.sh) - # https://github.com/docker-library/postgres/issues/450#issuecomment-393167936 - # https://github.com/docker-library/postgres/pull/452 - if [ -x "$f" ]; then - mysql_note "$0: running $f" - if ! "$f"; then - mysql_error "Failed to execute init script '$f'" - fi - else - mysql_note "$0: sourcing $f" - if ! . "$f"; then - mysql_error "Failed to source init script '$f'" - fi - fi - ;; - *.sql) mysql_note "$0: running $f"; docker_process_sql < "$f"; echo ;; - *.sql.bz2) mysql_note "$0: running $f"; bunzip2 -c "$f" | docker_process_sql; echo ;; - *.sql.gz) mysql_note "$0: running $f"; gunzip -c "$f" | docker_process_sql; echo ;; - *.sql.xz) mysql_note "$0: running $f"; xzcat "$f" | docker_process_sql; echo ;; - *.sql.zst) mysql_note "$0: running $f"; zstd -dc "$f" | docker_process_sql; echo ;; - *) mysql_warn "$0: ignoring $f" ;; - esac - echo - done + mysql_note "Running init scripts" + local f + for f; do + case "$f" in + *.sh) + # https://github.com/docker-library/postgres/issues/450#issuecomment-393167936 + # https://github.com/docker-library/postgres/pull/452 + if [ -x "$f" ]; then + mysql_note "$0: running $f" + if ! "$f"; then + mysql_error "Failed to execute init script '$f'" + fi + else + mysql_note "$0: sourcing $f" + if ! . "$f"; then + mysql_error "Failed to source init script '$f'" + fi + fi + ;; + *.sql) + mysql_note "$0: running $f" + docker_process_sql <"$f" + echo + ;; + *.sql.bz2) + mysql_note "$0: running $f" + bunzip2 -c "$f" | docker_process_sql + echo + ;; + *.sql.gz) + mysql_note "$0: running $f" + gunzip -c "$f" | docker_process_sql + echo + ;; + *.sql.xz) + mysql_note "$0: running $f" + xzcat "$f" | docker_process_sql + echo + ;; + *.sql.zst) + mysql_note "$0: running $f" + zstd -dc "$f" | docker_process_sql + echo + ;; + *) mysql_warn "$0: ignoring $f" ;; + esac + echo + done } # if there is config file provided through /etc/dolt/doltcfg.d, # we overwrite $HOME/.dolt/config_global.json file with this file. set_dolt_config_if_defined() { - get_config_file_path_if_exists "$DOLT_CONFIG_DIR" "json" - if [ ! -z "$CONFIG_PROVIDED" ]; then - if ! /bin/cp -rf "$CONFIG_PROVIDED" "$HOME/$DOLT_ROOT_PATH/config_global.json" 2>&1; then - mysql_error "Failed to copy config file from '$CONFIG_PROVIDED' to '$HOME/$DOLT_ROOT_PATH/config_global.json'. Check file permissions and paths." - fi + local config_file + config_file=$(get_config_file_path_if_exists "$DOLT_CONFIG_DIR" "json") + + if [ -n "$config_file" ]; then + if ! /bin/cp -rf "$config_file" "$HOME/$DOLT_ROOT_PATH/config_global.json" 2>&1; then + mysql_error "Failed to copy config file from '$config_file' to '$HOME/$DOLT_ROOT_PATH/config_global.json'. Check file permissions and paths." fi + fi } create_default_database_from_env() { - local user - local password - local database + local user + local password + local database - database=$(get_env_var "DATABASE") - user=$(get_env_var "USER") - password=$(get_env_var "PASSWORD") + database=$(get_env_var "DATABASE") + user=$(get_env_var "USER") + password=$(get_env_var "PASSWORD") - if [ -n "$database" ]; then - mysql_note "Creating database '${database}'" - local db_output - if ! db_output=$(dolt sql -q "CREATE DATABASE IF NOT EXISTS \`$database\`;" 2>&1); then - mysql_error "Failed to create database '$database'. Error: $db_output" - fi - fi + if [ -n "$database" ]; then + mysql_note "Creating database '${database}'" + execute_sql_with_error_capture "CREATE DATABASE IF NOT EXISTS \`$database\`;" "Failed to create database '$database'." + fi - if [ "$user" = 'root' ]; then - # TODO: add ALLOW_EMPTY_PASSWORD and RANDOM_ROOT_PASSWORD support -mysql_error <<-EOF + if [ "$user" = 'root' ]; then + # TODO: add ALLOW_EMPTY_PASSWORD and RANDOM_ROOT_PASSWORD support + mysql_error <<-EOF $(get_env_var_name "USER")="root", $(get_env_var_name "USER") and $(get_env_var_name "PASSWORD") are for configuring a regular user and cannot be used for the root user Remove $(get_env_var_name "USER")="root" and use the following to control the root user password: - DOLT_ROOT_PASSWORD EOF + fi + + if [ -n "$user" ] && [ -z "$password" ]; then + mysql_error "$(get_env_var_name "USER") specified, but missing $(get_env_var_name "PASSWORD"); user creation requires a password." + elif [ -z "$user" ] && [ -n "$password" ]; then + mysql_warn "$(get_env_var_name "PASSWORD") specified, but missing $(get_env_var_name "USER"); password will be ignored" + return + fi + + if [ -n "$user" ]; then + # Get user host from DOLT_USER_HOST/MYSQL_USER_HOST, fall back to DOLT_ROOT_HOST, then localhost + local user_host + user_host=$(get_env_var "USER_HOST") + user_host="${user_host:-${DOLT_ROOT_HOST:-localhost}}" + mysql_note "Creating user '${user}'" + execute_sql_with_error_capture "CREATE USER IF NOT EXISTS '$user'@'$user_host' IDENTIFIED BY '$password';" "Failed to create user '$user'." + + # Grant basic server access + mysql_note "Granting server access to user '${user}'" + execute_sql_with_error_capture "GRANT USAGE ON *.* TO '$user'@'$user_host';" "Failed to grant server access to user '$user'." + + if [ -n "$database" ]; then + mysql_note "Giving user '${user}' access to schema '${database}'" + execute_sql_with_error_capture "GRANT ALL ON \`$database\`.* TO '$user'@'$user_host';" "Failed to grant permissions to user '$user' on database '$database'." fi - - if [ -n "$user" ] && [ -z "$password" ]; then - mysql_error "$(get_env_var_name "USER") specified, but missing $(get_env_var_name "PASSWORD"); user creation requires a password." - elif [ -z "$user" ] && [ -n "$password" ]; then - mysql_warn "$(get_env_var_name "PASSWORD") specified, but missing $(get_env_var_name "USER"); password will be ignored" - return - fi - - if [ -n "$user" ]; then - # Get user host from DOLT_USER_HOST/MYSQL_USER_HOST, fall back to DOLT_ROOT_HOST, then localhost - local user_host - user_host=$(get_env_var "USER_HOST") - user_host="${user_host:-${DOLT_ROOT_HOST:-localhost}}" - mysql_note "Creating user '${user}'" - if ! dolt sql -q "CREATE USER IF NOT EXISTS '$user'@'$user_host' IDENTIFIED BY '$password';" >/dev/null 2>&1; then - mysql_error "Failed to create user '$user'. Check if user already exists or if there are permission issues." - fi - - # Grant basic server access - mysql_note "Granting server access to user '${user}'" - if ! dolt sql -q "GRANT USAGE ON *.* TO '$user'@'$user_host';" >/dev/null 2>&1; then - mysql_error "Failed to grant server access to user '$user'" - fi - - if [ -n "$database" ]; then - mysql_note "Giving user '${user}' access to schema '${database}'" - if ! dolt sql -q "GRANT ALL ON \`$database\`.* TO '$user'@'$user_host';" >/dev/null 2>&1; then - mysql_error "Failed to grant permissions to user '$user' on database '$database'" - fi - fi - fi + fi } _main() { - # check for dolt binary executable - check_for_dolt + # check for dolt binary executable + check_for_dolt - local dolt_version - dolt_version=$(dolt version | grep 'dolt version' | cut -f3 -d " ") - mysql_note "Entrypoint script for Dolt Server $dolt_version starting..." + local dolt_version + dolt_version=$(dolt version | grep 'dolt version' | cut -f3 -d " ") + mysql_note "Entrypoint script for Dolt Server $dolt_version starting..." - declare -g CONFIG_PROVIDED + # dolt config will be set if user provided a single json file in /etc/dolt/doltcfg.d directory. + # It will overwrite config_global.json file in $HOME/.dolt + set_dolt_config_if_defined - # dolt config will be set if user provided a single json file in /etc/dolt/doltcfg.d directory. - # It will overwrite config_global.json file in $HOME/.dolt - set_dolt_config_if_defined + # if there is a single yaml provided in /etc/dolt/servercfg.d directory, + # it will be used to start the server with --config flag + local server_config_file + server_config_file=$(get_config_file_path_if_exists "$SERVER_CONFIG_DIR" "yaml") + if [ -n "$server_config_file" ]; then + set -- "$@" --config="$server_config_file" + fi - # if there is a single yaml provided in /etc/dolt/servercfg.d directory, - # it will be used to start the server with --config flag - get_config_file_path_if_exists "$SERVER_CONFIG_DIR" "yaml" - if [ ! -z "$CONFIG_PROVIDED" ]; then - set -- "$@" --config="$CONFIG_PROVIDED" + # TODO: add support for MYSQL_ROOT_HOST and MYSQL_ROOT_PASSWORD + # Note: User creation will happen after server starts to avoid conflicts with default users + + if [[ ! -f $INIT_COMPLETED ]]; then + # run any file provided in /docker-entrypoint-initdb.d directory before the server starts + if ls /docker-entrypoint-initdb.d/* >/dev/null 2>&1; then + docker_process_init_files /docker-entrypoint-initdb.d/* + else + mysql_warn "No files found in /docker-entrypoint-initdb.d/ to process" fi + touch $INIT_COMPLETED + fi - # TODO: add support for MYSQL_ROOT_HOST and MYSQL_ROOT_PASSWORD - # Note: User creation will happen after server starts to avoid conflicts with default users + # Start server in background for user setup + mysql_note "Starting Dolt server in background..." + dolt sql-server --host=0.0.0.0 --port=3306 "$@" >/tmp/server.log 2>&1 & + local server_pid=$! - if [[ ! -f $INIT_COMPLETED ]]; then - # run any file provided in /docker-entrypoint-initdb.d directory before the server starts - if ls /docker-entrypoint-initdb.d/* >/dev/null 2>&1; then - docker_process_init_files /docker-entrypoint-initdb.d/* - else - mysql_warn "No files found in /docker-entrypoint-initdb.d/ to process" - fi - touch $INIT_COMPLETED + # Wait for server to be ready and all required capabilities to be functional + local max_attempts=30 + local attempt=0 + local last_error="" + while [ $attempt -lt $max_attempts ]; do + # Connectivity + local basic_output + if ! basic_output=$(dolt sql -q "SELECT 1;" 2>&1); then + last_error="$basic_output" + # MySQL system tables are accessible + elif ! mysql_output=$(dolt sql -q "SELECT COUNT(*) FROM mysql.user;" 2>&1); then + last_error="$mysql_output" + # Database creation capability + elif ! db_test_output=$(dolt sql -q "CREATE DATABASE IF NOT EXISTS __health_check_db__; DROP DATABASE __health_check_db__;" 2>&1); then + last_error="$db_test_output" + # User privilege queries work + elif ! priv_output=$(dolt sql -q "SELECT COUNT(*) FROM mysql.db;" 2>&1); then + last_error="$priv_output" + # Database access mode stability check + elif ! access_output=$(dolt sql -q "SHOW VARIABLES LIKE 'read_only';" 2>&1); then + last_error="$access_output" + else + mysql_note "Server initialization complete!" + sleep 1 + break fi + sleep 1 + attempt=$((attempt + 1)) + done - # Start server in background for user setup - mysql_note "Starting Dolt server in background..." - dolt sql-server --host=0.0.0.0 --port=3306 "$@" > /tmp/server.log 2>&1 & - local server_pid=$! + if [ $attempt -eq $max_attempts ]; then + mysql_error "Server failed to be ready within 30 seconds. Error: $last_error" + fi - # Wait for server to be ready and all required capabilities to be functional - local max_attempts=30 - local attempt=0 - local last_error="" - while [ $attempt -lt $max_attempts ]; do - # Connectivity - local basic_output - if ! basic_output=$(dolt sql -q "SELECT 1;" 2>&1); then - last_error="Basic connectivity failed: $basic_output" - # MySQL system tables are accessible - elif ! mysql_output=$(dolt sql -q "SELECT COUNT(*) FROM mysql.user;" 2>&1); then - last_error="MySQL system tables not accessible: $mysql_output" - # Database creation capability - elif ! db_test_output=$(dolt sql -q "CREATE DATABASE IF NOT EXISTS __health_check_db__; DROP DATABASE __health_check_db__;" 2>&1); then - last_error="Database creation/deletion not working: $db_test_output" - # User privilege queries work - elif ! priv_output=$(dolt sql -q "SELECT COUNT(*) FROM mysql.db;" 2>&1); then - last_error="Privilege tables not accessible: $priv_output" - else - mysql_note "Server initialization complete!" - break - fi - sleep 1 - attempt=$((attempt + 1)) - done + # Create root user with the specified host (defaults to localhost if not specified) + local root_host="${DOLT_ROOT_HOST:-localhost}" + mysql_note "Ensuring root@${root_host} superuser exists with password" - if [ $attempt -eq $max_attempts ]; then - mysql_error "Server failed to be ready within 30 seconds. Last error: $last_error" - fi + # Ensure root user exists with correct password and permissions + mysql_note "Configuring root@${root_host} user" + execute_sql_with_error_capture "CREATE USER IF NOT EXISTS 'root'@'${root_host}' IDENTIFIED BY '${DOLT_ROOT_PASSWORD}'; ALTER USER 'root'@'${root_host}' IDENTIFIED BY '${DOLT_ROOT_PASSWORD}'; GRANT ALL ON *.* TO 'root'@'${root_host}' WITH GRANT OPTION;" "Failed to configure root@${root_host} user." - # Create root user with the specified host (defaults to localhost if not specified) - local root_host="${DOLT_ROOT_HOST:-localhost}" - mysql_note "Ensuring root@${root_host} superuser exists with password" + # If DOLT_DATABASE or MYSQL_DATABASE has been specified, create the database if it does not exist + create_default_database_from_env - # Ensure root user exists with correct password and permissions - mysql_note "Configuring root@${root_host} user" - dolt sql -q "CREATE USER IF NOT EXISTS 'root'@'${root_host}' IDENTIFIED BY '${DOLT_ROOT_PASSWORD}'; ALTER USER 'root'@'${root_host}' IDENTIFIED BY '${DOLT_ROOT_PASSWORD}'; GRANT ALL ON *.* TO 'root'@'${root_host}' WITH GRANT OPTION;" >/dev/null 2>&1 || mysql_error "Failed to configure root@${root_host} user." + # Show what users exist for debugging + mysql_note "Current users in the system:" + dolt sql -q "SELECT User, Host FROM mysql.user;" 2>&1 | grep -v "^$" || mysql_warn "Could not list users" - # If DOLT_DATABASE or MYSQL_DATABASE has been specified, create the database if it does not exist - create_default_database_from_env + mysql_note "Reattaching to server process..." + cat /tmp/server.log - # Show what users exist for debugging - mysql_note "Current users in the system:" - dolt sql -q "SELECT User, Host FROM mysql.user;" 2>&1 | grep -v "^$" || mysql_warn "Could not list users" + # Kill the background process and restart in foreground to show live output + kill $server_pid 2>/dev/null || true + wait $server_pid 2>/dev/null || true - mysql_note "Reattaching to server process..." - cat /tmp/server.log - - # Kill the background process and restart in foreground to show live output - kill $server_pid 2>/dev/null || true - wait $server_pid 2>/dev/null || true - - # Start server in foreground to show live output - exec dolt sql-server --host=0.0.0.0 --port=3306 "$@" + # Start server in foreground to show live output + exec dolt sql-server --host=0.0.0.0 --port=3306 "$@" } _main "$@" diff --git a/integration-tests/bats/docker-entrypoint.bats b/integration-tests/bats/docker-entrypoint.bats index 787b8d8250..4ce93add74 100644 --- a/integration-tests/bats/docker-entrypoint.bats +++ b/integration-tests/bats/docker-entrypoint.bats @@ -644,21 +644,43 @@ EOF usr="testuser" pwd="testpass" - # Test multiple start/stop cycles to verify health check robustness + # Launch all 20 containers simultaneously to stress the system + local total_cycles=20 + + echo "# Starting all $total_cycles containers simultaneously" >&3 + + # Start all containers at the same time + local pids=() + for cycle in {1..20}; do + ( + docker run -d --name "$cname-$cycle" \ + -e DOLT_ROOT_PASSWORD=rootpass \ + -e DOLT_ROOT_HOST=% \ + -e DOLT_DATABASE="$db" \ + -e DOLT_USER="$usr" \ + -e DOLT_PASSWORD="$pwd" \ + "$TEST_IMAGE" >/dev/null 2>&1 + ) & + pids+=($!) + done + + # Wait for all containers to start + echo "# Waiting for all containers to start..." >&3 + for pid in "${pids[@]}"; do + wait $pid + done + + # Wait for all servers to be ready + echo "# Waiting for all servers to be ready..." >&3 + for cycle in {1..20}; do + wait_for_log "$cname-$cycle" "Server ready. Accepting connections." 30 + wait_for_log "$cname-$cycle" "Reattaching to server process" 15 || true + done + + echo "# All containers started, verifying logs..." >&3 + + # Verify no errors in any container logs for cycle in {1..20}; do - echo "# Starting server cycle $cycle" >&3 - - # Start container with user and database configuration - run_container_with_port "$cname-$cycle" 3306 \ - -e DOLT_ROOT_PASSWORD=rootpass \ - -e DOLT_ROOT_HOST=% \ - -e DOLT_DATABASE="$db" \ - -e DOLT_USER="$usr" \ - -e DOLT_PASSWORD="$pwd" - - echo "# Server cycle $cycle completed successfully" >&3 - - # Verify no errors in container logs run docker logs "$cname-$cycle" 2>&1 [ $status -eq 0 ] # Should not contain ERROR messages (but allow warnings) @@ -666,17 +688,26 @@ EOF # Should contain success indicators [[ "$output" =~ "Server initialization complete" ]] || false [[ "$output" =~ "Server ready. Accepting connections" ]] || false - - # Stop the container - run docker stop "$cname-$cycle" - [ $status -eq 0 ] - - # Clean up for next cycle - run docker rm "$cname-$cycle" - [ $status -eq 0 ] - - echo "# Server cycle $cycle stopped and cleaned up" >&3 done - echo "# All $cycle server start/stop cycles completed successfully" >&3 + echo "# All logs verified, stopping all containers..." >&3 + + # Stop all containers simultaneously + local stop_pids=() + for cycle in {1..20}; do + docker stop "$cname-$cycle" >/dev/null & + stop_pids+=($!) + done + + # Wait for all stops to complete + for pid in "${stop_pids[@]}"; do + wait $pid + done + + # Clean up all containers + for cycle in {1..20}; do + docker rm "$cname-$cycle" >/dev/null + done + + echo "# All $total_cycles containers completed successfully" >&3 }