Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,20 @@ Whether or not to leave the container after the run, or immediately remove it wi

Default: `false`

### `reuse-container` (optional, boolean)

When set to `true`, the plugin keeps a named container running across pipeline steps instead of creating and destroying one each time. On each step, the plugin checks for an existing container: if the image digest matches, the command runs via `docker exec`; if the digest differs, the old container is removed and a fresh one is created. Environment variables are injected per-exec only (not baked into the container) to prevent secrets from leaking between jobs.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you should add a note with a huge warning about turning on this option when running commands that have side-effects on the container or file system as those would be preserved between runs or pipelines


The container name is derived from the image name and the agent's spawn index. If the agent name does not follow the `name-%spawn` convention, set `reuse-container-name` to avoid potential container name collisions between agents on the same host.

Default: `false`

### `reuse-container-name` (optional, string)

Override the auto-generated container name used by the `reuse-container` feature. Use this when the default name derivation does not produce unique names (for example, when multiple agents on the same host do not use the standard `-%spawn` agent name suffix).

Example: `my-build-container`

### `log-driver` (optional, string)

The logging driver for the container. This allows you to configure how Docker handles logs for the container.
Expand Down
153 changes: 134 additions & 19 deletions commands/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ if [[ "${BUILDKITE_PLUGIN_DOCKER_INTERACTIVE:-$interactive_default}" =~ ^(true|o
args+=("-i")
fi

if [[ ! "${BUILDKITE_PLUGIN_DOCKER_LEAVE_CONTAINER:-off}" =~ ^(true|on|1)$ ]] ; then
if [[ ! "${BUILDKITE_PLUGIN_DOCKER_LEAVE_CONTAINER:-off}" =~ ^(true|on|1)$ ]] \
&& [[ ! "${BUILDKITE_PLUGIN_DOCKER_REUSE_CONTAINER:-false}" =~ ^(true|on|1)$ ]] ; then
args+=("--rm")
fi

Expand Down Expand Up @@ -543,6 +544,11 @@ if [[ "${BUILDKITE_PLUGIN_DOCKER_RUN_LABELS:-true}" =~ ^(true|on|1)$ ]] ; then
)
fi

# Snapshot run flags before image/shell/command for reuse-container mode.
# These are pure "docker run" flags (volumes, env, network, etc.) without the
# trailing image or command, which lets us reuse them for container creation.
run_flags=("${args[@]}")

# Add the image in before the shell and command
args+=("${image}")

Expand Down Expand Up @@ -591,28 +597,137 @@ elif [[ ${#command[@]} -gt 0 ]] ; then
done
fi

echo "--- :docker: Running command in ${image}"
echo -ne '\033[90m$\033[0m docker run ' >&2
if [[ "${BUILDKITE_PLUGIN_DOCKER_REUSE_CONTAINER:-false}" =~ ^(true|on|1)$ ]]; then
# --- Reuse-container path ---
container_name=$(get_reuse_container_name "${image}")

# Build exec_args by extracting only the flags that docker exec supports
# from the run_flags snapshot (tty, interactive, env, workdir, user).
exec_args=()
i=0
while [[ $i -lt ${#run_flags[@]} ]]; do
case "${run_flags[$i]}" in
-t|-i)
exec_args+=("${run_flags[$i]}")
;;
--env|--env-file|--workdir|-u)
exec_args+=("${run_flags[$i]}" "${run_flags[$((i+1))]}")
i=$((i+1))
;;
esac
i=$((i+1))
done
Comment on lines +604 to +619
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be more readable to create a separate argument array for the exec codepath or, even better, an array for the options available on both exec and run and another for the ones exclusive to run


# Build the command to execute inside the container
exec_cmd=()
if [[ ${#shell[@]} -gt 0 ]]; then
for shell_arg in "${shell[@]}"; do
exec_cmd+=("$shell_arg")
done
fi
if [[ -n "${BUILDKITE_COMMAND}" ]]; then
if is_windows; then
windows_multi_command=${BUILDKITE_COMMAND//$'\n'/ && }
exec_cmd+=("${windows_multi_command}")
else
exec_cmd+=("${BUILDKITE_COMMAND}")
fi
elif [[ ${#command[@]} -gt 0 ]]; then
for command_arg in "${command[@]}"; do
exec_cmd+=("$command_arg")
done
fi
Comment on lines +622 to +639
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here: why duplicate this logic that is already done above for the run command? It would make more sense to save all this to a separate array that is the same for both run and exec


# Print all the arguments, with a space after, properly shell quoted
printf "%q " "${args[@]}"
echo
need_create=true

# Disable -e outside of the subshell; since the subshell returning a failure
# would exit the parent shell (here) early.
set +e
# Check if container already exists
container_running=$(docker container inspect --format '{{.State.Running}}' "${container_name}" 2>/dev/null || true)

# Prevent SIGTERM from killing this script. SIGTERM will still be passed to the Docker container, which can exit
# gracefully (or, if necessary, non-gracefully per the `--stop-timeout` flag passed above).
trap '' SIGTERM
if [[ "${container_running}" == "true" ]]; then
container_image_id=$(get_container_image_id "${container_name}")
expected_image_id=$(get_image_id "${image}")
if [[ -n "${expected_image_id}" ]] && [[ "${container_image_id}" == "${expected_image_id}" ]]; then
echo "--- :docker: Reusing existing container ${container_name} (${image})"
need_create=false
else
echo "+++ WARNING: Container image mismatch for ${container_name}"
echo " Expected image: ${image} (${expected_image_id:-unknown})"
echo " Container image ID: ${container_image_id:-unknown}"
echo " Removing old container and creating a new one."
docker rm -f "${container_name}"
fi
elif [[ -n "${container_running}" ]]; then
echo "--- :docker: Removing stopped container ${container_name}"
docker rm -f "${container_name}"
fi

# Don't convert paths on gitbash on windows, as that can mangle user paths and cmd options.
# See https://github.com/buildkite-plugins/docker-buildkite-plugin/issues/81 for more information.
# `trap` is used in this subshell for the same reason it is used above.
( if is_windows ; then export MSYS_NO_PATHCONV=1; fi && trap '' SIGTERM && docker run "${args[@]}" )
if [[ "${need_create}" == "true" ]]; then
# Strip --env and --env-file from run_flags for the creation run.
# The detached container only runs "sleep infinity" and needs no env vars.
# All job env vars are injected per-exec to avoid leaking secrets from
# one job's environment into subsequent jobs that reuse the container.
create_flags=()
i=0
while [[ $i -lt ${#run_flags[@]} ]]; do
case "${run_flags[$i]}" in
--env|--env-file)
i=$((i+1))
;;
*)
create_flags+=("${run_flags[$i]}")
;;
esac
i=$((i+1))
done

echo "--- :docker: Creating persistent container ${container_name} (${image})"
echo -ne '\033[90m$\033[0m docker run -d --name ' >&2
echo -n "${container_name} " >&2
printf "%q " "${create_flags[@]}" >&2
echo "--entrypoint '' ${image} sleep infinity" >&2

docker run -d --name "${container_name}" "${create_flags[@]}" \
--entrypoint "" "${image}" sleep infinity >/dev/null
fi

exit_code=$?
echo "--- :docker: Executing command in container ${container_name}"
echo -ne '\033[90m$\033[0m docker exec ' >&2
printf "%q " "${exec_args[@]}" "${container_name}" "${exec_cmd[@]}" >&2
echo >&2

set -e
set +e
trap '' SIGTERM
( if is_windows; then export MSYS_NO_PATHCONV=1; fi && trap '' SIGTERM && docker exec "${exec_args[@]}" "${container_name}" "${exec_cmd[@]}" )

exit $exit_code # propagate exit code
exit_code=$?
set -e
exit $exit_code

else
# --- Normal path ---
echo "--- :docker: Running command in ${image}"
echo -ne '\033[90m$\033[0m docker run ' >&2

# Print all the arguments, with a space after, properly shell quoted
printf "%q " "${args[@]}"
echo

# Disable -e outside of the subshell; since the subshell returning a failure
# would exit the parent shell (here) early.
set +e

# Prevent SIGTERM from killing this script. SIGTERM will still be passed to the Docker container, which can exit
# gracefully (or, if necessary, non-gracefully per the `--stop-timeout` flag passed above).
trap '' SIGTERM

# Don't convert paths on gitbash on windows, as that can mangle user paths and cmd options.
# See https://github.com/buildkite-plugins/docker-buildkite-plugin/issues/81 for more information.
# `trap` is used in this subshell for the same reason it is used above.
( if is_windows ; then export MSYS_NO_PATHCONV=1; fi && trap '' SIGTERM && docker run "${args[@]}" )

exit_code=$?

set -e

exit $exit_code
Comment on lines +715 to +732
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this whole block is common to both branches, I think it could be simplified with a better crafting (i.e. replacing with a better version) of the ${args[@]}

fi
4 changes: 3 additions & 1 deletion hooks/pre-exit
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd)"
# shellcheck source=lib/shared.bash
. "$DIR/../lib/shared.bash"

if [[ "${BUILDKITE_PLUGIN_DOCKER_CLEANUP:-true}" =~ ^(true|on|1)$ ]] ; then
if [[ "${BUILDKITE_PLUGIN_DOCKER_REUSE_CONTAINER:-false}" =~ ^(true|on|1)$ ]] ; then
echo "~~~ Skipping container cleanup (reuse-container is enabled)"
elif [[ "${BUILDKITE_PLUGIN_DOCKER_CLEANUP:-true}" =~ ^(true|on|1)$ ]] ; then
for container in $(docker ps -a -q --filter "label=com.buildkite.job-id=${BUILDKITE_JOB_ID}") ; do
echo "~~~ Cleaning up left-over container ${container}"
docker stop "$container"
Expand Down
38 changes: 38 additions & 0 deletions lib/shared.bash
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,41 @@ function is_macos() {
[[ "$OSTYPE" =~ ^(darwin) ]]
}

# Returns a stable container name for the reuse-container feature.
# Uses the explicit override if set, otherwise derives from the image name
# and the agent's spawn index (to isolate containers per agent on a host).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should also be isolated per pipeline, otherwise you could run into issues with a completely different repositories being mounted (if they use the same image to run)

function get_reuse_container_name() {
local image="$1"

if [[ -n "${BUILDKITE_PLUGIN_DOCKER_REUSE_CONTAINER_NAME:-}" ]]; then
echo "${BUILDKITE_PLUGIN_DOCKER_REUSE_CONTAINER_NAME}"
return
fi

local sanitized="${image//[^a-zA-Z0-9_.-]/-}"
local name="${sanitized}"

local spawn_suffix="${BUILDKITE_AGENT_NAME##*-}"
if [[ "${spawn_suffix}" =~ ^[0-9]+$ ]]; then
name="${name}-${spawn_suffix}"
else
echo "Warning: Could not extract numeric spawn index from BUILDKITE_AGENT_NAME '${BUILDKITE_AGENT_NAME}'." >&2
echo " Multiple agents on the same host may share container name '${name}'." >&2
echo " Set 'reuse-container-name' to specify an explicit container name." >&2
fi

echo "${name}"
}

# Returns the image ID (digest) of the image a container was created from.
function get_container_image_id() {
local container_name="$1"
docker inspect --format '{{.Image}}' "${container_name}" 2>/dev/null
}

# Returns the image ID (digest) of a local image.
function get_image_id() {
local image="$1"
docker image inspect --format '{{.Id}}' "${image}" 2>/dev/null
}

4 changes: 4 additions & 0 deletions plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ configuration:
type: boolean
privileged:
type: boolean
reuse-container:
type: boolean
reuse-container-name:
type: string
publish:
type: array
init:
Expand Down
Loading