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.

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

# 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

# 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
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).
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