fix: keep the git source-image property in sync with failed deploys

The source-image property was written before the deploy lock was taken
and was not restored when a deploy failed, so after a rejected
git:from-image or git:load-image it named an image that was not
deployed, and later builds extracted app.json, Procfile, and CHECKS
from it. Deploys from other sources also built while the old image was
still set.

Entry points now pass the desired value via DOKKU_GIT_SOURCE_IMAGE and
git_receive_app applies it while holding the deploy lock, restoring the
previous value on failure. git:sync clears the property under the lock
when it fetches new code.

Closes #9117
This commit is contained in:
Jose Diaz-Gonzalez
2026-10-11 05:08:32 -04:00
parent 5eaab7cc1a
commit 7955e8ee06
9 changed files with 383 additions and 24 deletions

View File

@@ -170,6 +170,8 @@ By default, when running `git:sync` with a git branch reference, Dokku will auto
dokku git:sync --skip-deploy-branch node-js-app https://github.com/heroku/node-js-getting-started.git main
```
The `git:sync` command holds the app's deploy lock while fetching, and will fail if a deploy is already in progress. When the fetched code changes the repository or a build is triggered, the `source-image` property set by [image deploys](/docs/deployment/methods/image.md) is cleared. As the synced code is kept even when a build fails, the property is not restored on build failure.
### Initializing from private repositories
> [!IMPORTANT]

View File

@@ -59,6 +59,8 @@ dokku git:from-image --build-dir path/to/build node-js-app domy-registrykku/node
See the [dockerfile documentation](/docs/deployment/builders/dockerfiles.md) to learn about the different ways to configure Dockerfile-based deploys.
The image used for a `git:from-image` or `git:load-image` deploy is stored in the `source-image` git property, which can be viewed via `dokku git:report node-js-app --git-source-image`. This property is only changed while the app's deploy lock is held. If the deploy fails, the deploy branch is rolled back and the property is restored to its previous value, so it always refers to the image the repository was last successfully deployed from. Deploying via other methods - such as `git push`, `git:from-archive`, or `git:sync` - clears the property.
## Initializing an app repository from a remote image without a registry
> [!IMPORTANT]

View File

@@ -88,12 +88,12 @@ cmd-git-hook() {
# Only run this script for the master branch. You can remove this
# if block if you wish to run it for others as well.
if [[ $refname == "refs/heads/${DOKKU_DEPLOY_BRANCH}" ]] || [[ $refname == "refs/tags/${DOKKU_DEPLOY_BRANCH}" ]]; then
git_receive_app "$APP" "$newrev"
DOKKU_GIT_SOURCE_IMAGE="" git_receive_app "$APP" "$newrev"
plugn trigger deploy-source-set "$APP" "git-push" "$newrev"
else
# broken out into plugin so we might support other methods to receive an app
if [[ $(find "$PLUGIN_PATH"/enabled/*/receive-branch 2>/dev/null | wc -l) != 1 ]]; then
plugn trigger receive-branch "$APP" "$newrev" "$refname"
DOKKU_GIT_SOURCE_IMAGE="" plugn trigger receive-branch "$APP" "$newrev" "$refname"
plugn trigger deploy-source-set "$APP" "git-push" "$newrev"
elif [[ -z "$(fn-git-computed-deploy-branch "$APP" "")" ]]; then
local deploy_branch="${refname/refs\/heads\//}"
@@ -101,7 +101,7 @@ cmd-git-hook() {
echo $'\e[1G\e[K'"-----> Set ${deploy_branch} as deploy-branch"
fn-plugin-property-write "git" "$APP" "deploy-branch" "${deploy_branch}"
git_receive_app "$APP" "$newrev"
DOKKU_GIT_SOURCE_IMAGE="" git_receive_app "$APP" "$newrev"
plugn trigger deploy-source-set "$APP" "git-push" "$newrev"
else
echo $'\e[1G\e[K'"-----> WARNING: deploy did not complete, you must push to ${DOKKU_DEPLOY_BRANCH}."
@@ -150,9 +150,23 @@ git_receive_app() {
if [[ ! -d "$DOKKU_ROOT/$APP/refs" ]]; then
true
else
local exit_code=0
local exit_code=0 has_source_image=false source_image previous_source_image
acquire_app_deploy_lock "$APP" "exclusive"
git_build "$APP" "$REV" || exit_code="$?"
# DOKKU_GIT_SOURCE_IMAGE is only applied while the deploy lock is held. An
# empty value clears the property, while an unset variable leaves it as-is.
if [[ -n "${DOKKU_GIT_SOURCE_IMAGE+x}" ]]; then
has_source_image=true
source_image="$DOKKU_GIT_SOURCE_IMAGE"
unset DOKKU_GIT_SOURCE_IMAGE
previous_source_image="$(fn-git-source-image "$APP")"
fn-plugin-property-write "git" "$APP" "source-image" "$source_image"
fi
(git_build "$APP" "$REV") || exit_code="$?"
if [[ "$exit_code" -ne 0 ]] && [[ "$has_source_image" == "true" ]]; then
fn-plugin-property-write "git" "$APP" "source-image" "$previous_source_image"
fi
release_app_deploy_lock "$APP" "$exit_code"
return "$exit_code"
fi

View File

@@ -51,7 +51,7 @@ trigger-git-git-from-archive() {
popd &>/dev/null || pushd "/tmp" >/dev/null
fi
plugn trigger git-from-directory "$APP" "$TMP_WORK_DIR" "$USER_NAME" "$USER_EMAIL"
DOKKU_GIT_SOURCE_IMAGE="" plugn trigger git-from-directory "$APP" "$TMP_WORK_DIR" "$USER_NAME" "$USER_EMAIL"
}
trigger-git-git-from-archive "$@"

View File

@@ -60,7 +60,8 @@ trigger-git-git-from-directory() {
fi
rm -rf "$TMP_WORK_DIR" "$TMP_WORK_DIR_2" >/dev/null
if git_receive_app "$APP" "$REV"; then
# run in a subshell so a failure to acquire the deploy lock still reaches the rollback
if (git_receive_app "$APP" "$REV"); then
return
else
local exit_code=$?

View File

@@ -44,8 +44,7 @@ trigger-git-git-from-image() {
dokku_log_info1 "Image exists on host, skipping pull"
fi
fn-plugin-property-write "git" "$APP" "source-image" "$DOCKER_IMAGE"
plugn trigger git-from-directory "$APP" "$TMP_WORK_DIR" "$USER_NAME" "$USER_EMAIL"
DOKKU_GIT_SOURCE_IMAGE="$DOCKER_IMAGE" plugn trigger git-from-directory "$APP" "$TMP_WORK_DIR" "$USER_NAME" "$USER_EMAIL"
}
trigger-git-git-from-image "$@"

View File

@@ -279,33 +279,59 @@ cmd-git-sync() {
fi
local APP_ROOT="$DOKKU_ROOT/$APP"
DOKKU_DEPLOY_BRANCH="$(fn-git-computed-deploy-branch "$APP")"
CURRENT_REF="$(fn-git-cmd "$APP_ROOT" rev-parse "$DOKKU_DEPLOY_BRANCH" 2>/dev/null || true)"
local APP_DEPLOY_LOCK_FILE="$DOKKU_LIB_ROOT/data/apps/$APP/.deploy.lock"
local fetch_exit_code=0
dokku_setup_build_capture "$APP" "git:sync"
if ! fn-git-cmd "$APP_ROOT" rev-parse "$DOKKU_DEPLOY_BRANCH" &>/dev/null; then
dokku_log_info1_quiet "Cloning $APP from $GIT_REMOTE#$GIT_REF"
fn-git-clone "$APP" "$GIT_REMOTE" "$GIT_REF" "$SKIP_DEPLOY_BRANCH"
else
dokku_log_verbose "Fetching remote code for $APP from $GIT_REMOTE#$GIT_REF"
fn-git-fetch "$APP" "$GIT_REMOTE" "$GIT_REF"
# acquire_app_deploy_lock is not used as releasing it would finalize the
# build record before the build starts
acquire_advisory_lock "$APP_DEPLOY_LOCK_FILE" "exclusive" "$APP currently has a deploy lock in place. Waiting..." "$APP currently has a deploy lock in place. Exiting..."
DOKKU_DEPLOY_BRANCH="$(fn-git-computed-deploy-branch "$APP")"
CURRENT_REF="$(fn-git-cmd "$APP_ROOT" rev-parse "$DOKKU_DEPLOY_BRANCH" 2>/dev/null || true)"
# the subshell keeps set -e active and ensures the lock is released on failure
set +e
(
set -e
if ! fn-git-cmd "$APP_ROOT" rev-parse "$DOKKU_DEPLOY_BRANCH" &>/dev/null; then
dokku_log_info1_quiet "Cloning $APP from $GIT_REMOTE#$GIT_REF"
fn-git-clone "$APP" "$GIT_REMOTE" "$GIT_REF" "$SKIP_DEPLOY_BRANCH"
else
dokku_log_verbose "Fetching remote code for $APP from $GIT_REMOTE#$GIT_REF"
fn-git-fetch "$APP" "$GIT_REMOTE" "$GIT_REF"
fi
)
fetch_exit_code="$?"
set -e
if [[ "$fetch_exit_code" -ne 0 ]]; then
release_advisory_lock "$APP_DEPLOY_LOCK_FILE"
return "$fetch_exit_code"
fi
UPDATED_REF="$(fn-git-cmd "$APP_ROOT" rev-parse "$DOKKU_DEPLOY_BRANCH" 2>/dev/null || true)"
local SHOULD_BUILD=false
if [[ "$FLAG" == "--build" ]]; then
SHOULD_BUILD=true
elif [[ "$FLAG" == "--build-if-changes" ]]; then
if [[ "$CURRENT_REF" == "$UPDATED_REF" ]]; then
dokku_log_verbose "Skipping build as no changes were detected"
dokku_discard_build_capture "$APP"
return
fi
elif [[ "$FLAG" == "--build-if-changes" ]] && [[ "$CURRENT_REF" != "$UPDATED_REF" ]]; then
SHOULD_BUILD=true
fi
# the synced code is kept even if the build fails, so the source-image is
# cleared rather than restored on failure
if [[ "$CURRENT_REF" != "$UPDATED_REF" ]] || [[ "$SHOULD_BUILD" == "true" ]]; then
fn-plugin-property-write "git" "$APP" "source-image"
fi
release_advisory_lock "$APP_DEPLOY_LOCK_FILE"
if [[ "$FLAG" == "--build-if-changes" ]] && [[ "$SHOULD_BUILD" == "false" ]]; then
dokku_log_verbose "Skipping build as no changes were detected"
dokku_discard_build_capture "$APP"
return
fi
if [[ "$SHOULD_BUILD" == "true" ]]; then
if [[ -n "$GIT_REF" ]]; then
GIT_REF="$(fn-git-cmd "$APP_ROOT" rev-parse "$GIT_REF")"

View File

@@ -10,11 +10,74 @@ setup() {
}
teardown() {
release_deploy_lock
docker image rm "dokku-test/$TEST_APP:second-procfile" "dokku-test/$TEST_APP:default-procfile" &>/dev/null || true
rm -f /home/dokku/.ssh/id_rsa.pub || true
destroy_app
global_teardown
}
build_python_image() {
declare TAG="$1" PROCFILE="$2"
local TMP_IMAGE_DIR exit_code=0
TMP_IMAGE_DIR="$(mktemp -d "/tmp/${DOKKU_DOMAIN}.XXXXX")"
cp -r "${BATS_TEST_DIRNAME}/../../tests/apps/python/." "$TMP_IMAGE_DIR"
if [[ -n "$PROCFILE" ]]; then
cp "$TMP_IMAGE_DIR/$PROCFILE" "$TMP_IMAGE_DIR/Procfile"
fi
docker image build -t "dokku-test/$TEST_APP:$TAG" -f "$TMP_IMAGE_DIR/alt.Dockerfile" "$TMP_IMAGE_DIR" || exit_code="$?"
rm -rf "$TMP_IMAGE_DIR"
return "$exit_code"
}
deploy_second_procfile_image() {
run build_python_image second-procfile second.Procfile
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku git:from-image $TEST_APP dokku-test/$TEST_APP:second-procfile"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku plugin:trigger procfile-get-command $TEST_APP web 5001"
echo "output: $output"
echo "status: $status"
assert_success
assert_output_contains "second.Procfile"
}
hold_deploy_lock() {
DEPLOY_LOCK_FILE="$DOKKU_LIB_ROOT/data/apps/$TEST_APP/.deploy.lock"
sudo -u dokku flock -o "$DEPLOY_LOCK_FILE" sleep 120 >/dev/null 2>&1 3>&- &
DEPLOY_LOCK_HOLDER_PID="$!"
local i
for i in $(seq 1 20); do
if ! flock -n "$DEPLOY_LOCK_FILE" true; then
return 0
fi
sleep 0.5
done
echo "unable to acquire the deploy lock for $TEST_APP"
return 1
}
release_deploy_lock() {
if [[ -z "$DEPLOY_LOCK_HOLDER_PID" ]]; then
return 0
fi
kill "$DEPLOY_LOCK_HOLDER_PID" &>/dev/null || true
wait "$DEPLOY_LOCK_HOLDER_PID" &>/dev/null || true
pkill -u dokku -f "flock -o $DEPLOY_LOCK_FILE" &>/dev/null || true
rm -f "$DEPLOY_LOCK_FILE"
DEPLOY_LOCK_HOLDER_PID=""
}
@test "(git) git:from-image [missing]" {
run /bin/bash -c "dokku git:from-image $TEST_APP dokku/python:missing-tag"
echo "output: $output"
@@ -141,6 +204,12 @@ teardown() {
assert_failure
assert_output "fatal: this operation must be run in a work tree"
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output ""
run /bin/bash -c "dokku config:set --no-restart $TEST_APP FAIL_ON_STARTUP=false"
echo "output: $output"
echo "status: $status"
@@ -151,6 +220,12 @@ teardown() {
echo "status: $status"
assert_success
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "dokku-test/$TEST_APP:latest"
run /bin/bash -c "dokku config:set --no-restart $TEST_APP FAIL_ON_STARTUP=true"
echo "output: $output"
echo "status: $status"
@@ -161,6 +236,12 @@ teardown() {
echo "status: $status"
assert_failure
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "dokku-test/$TEST_APP:latest"
run /bin/bash -c "dokku config:set --no-restart $TEST_APP FAIL_ON_STARTUP=false"
echo "output: $output"
echo "status: $status"
@@ -172,6 +253,12 @@ teardown() {
assert_success
assert_output_contains "No changes detected, skipping git commit" 0
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "dokku-test/$TEST_APP:v2"
run /bin/bash -c "docker image rm dokku-test/$TEST_APP:latest dokku-test/$TEST_APP:v2"
echo "output: $output"
echo "status: $status"
@@ -249,3 +336,207 @@ EOF
assert_success
assert_output_contains "linuxserver/foldingathome:7.6.21"
}
@test "(git) git:from-image source-image after failed deploy and ps:rebuild" {
deploy_second_procfile_image
run build_python_image default-procfile
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku config:set --no-restart $TEST_APP FAIL_ON_STARTUP=true"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku git:from-image $TEST_APP dokku-test/$TEST_APP:default-procfile"
echo "output: $output"
echo "status: $status"
assert_failure
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "dokku-test/$TEST_APP:second-procfile"
run /bin/bash -c "dokku config:set --no-restart $TEST_APP FAIL_ON_STARTUP=false"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku ps:rebuild $TEST_APP"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "dokku-test/$TEST_APP:second-procfile"
run /bin/bash -c "dokku plugin:trigger procfile-get-command $TEST_APP web 5001"
echo "output: $output"
echo "status: $status"
assert_success
assert_output_contains "second.Procfile"
assert_output_contains "first.Procfile" 0
}
@test "(git) git:from-image then git:from-archive ignores the previous source-image" {
deploy_second_procfile_image
run /bin/bash -c "dokku config:set --no-restart $TEST_APP FAIL_ON_STARTUP=true"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "tar -C ${BATS_TEST_DIRNAME}/../../tests/apps -cf - python | dokku git:from-archive $TEST_APP --"
echo "output: $output"
echo "status: $status"
assert_failure
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "dokku-test/$TEST_APP:second-procfile"
run /bin/bash -c "dokku config:set --no-restart $TEST_APP FAIL_ON_STARTUP=false"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "tar -C ${BATS_TEST_DIRNAME}/../../tests/apps -cf - python | dokku git:from-archive $TEST_APP --"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output ""
run /bin/bash -c "dokku plugin:trigger procfile-get-command $TEST_APP web 5001"
echo "output: $output"
echo "status: $status"
assert_success
assert_output_contains "first.Procfile"
assert_output_contains "second.Procfile" 0
}
@test "(git) git:from-image then git push ignores the previous source-image" {
local TMP=$(mktemp -d "/tmp/${DOKKU_DOMAIN}.XXXXX")
trap 'rm -rf "$TMP"' INT TERM
deploy_second_procfile_image
rmdir "$TMP" && cp -r "${BATS_TEST_DIRNAME}/../../tests/apps/python" "$TMP"
run /bin/bash -c "cd $TMP && git init -q && git checkout -q -b master && git config user.email 'robot@example.com' && git config user.name 'Test Robot' && git add . && git commit -q -m 'initial commit' && git push --force dokku@${DOKKU_DOMAIN}:$TEST_APP master:master"
echo "output: $output"
echo "status: $status"
assert_success
rm -rf "$TMP"
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output ""
run /bin/bash -c "dokku plugin:trigger procfile-get-command $TEST_APP web 5001"
echo "output: $output"
echo "status: $status"
assert_success
assert_output_contains "first.Procfile"
assert_output_contains "second.Procfile" 0
}
@test "(git) git:from-image then git:sync clears the source-image" {
deploy_second_procfile_image
run /bin/bash -c "dokku git:sync $TEST_APP https://github.com/dokku/smoke-test-app.git 2.0.0"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output ""
run /bin/bash -c "dokku git:from-image $TEST_APP dokku-test/$TEST_APP:second-procfile"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "dokku-test/$TEST_APP:second-procfile"
run /bin/bash -c "dokku git:sync --build $TEST_APP https://github.com/dokku/smoke-test-app.git 2.0.0"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output ""
}
@test "(git) source-image is unchanged when the deploy lock is held" {
deploy_second_procfile_image
run build_python_image default-procfile
echo "output: $output"
echo "status: $status"
assert_success
hold_deploy_lock
run /bin/bash -c "dokku git:from-image $TEST_APP dokku-test/$TEST_APP:default-procfile"
echo "output: $output"
echo "status: $status"
assert_failure
assert_output_contains "currently has a deploy lock in place"
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "dokku-test/$TEST_APP:second-procfile"
run /bin/bash -c "dokku git:sync $TEST_APP https://github.com/dokku/smoke-test-app.git 2.0.0"
echo "output: $output"
echo "status: $status"
assert_failure
assert_output_contains "currently has a deploy lock in place"
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "dokku-test/$TEST_APP:second-procfile"
release_deploy_lock
run /bin/bash -c "dokku git:from-image $TEST_APP dokku-test/$TEST_APP:default-procfile"
echo "output: $output"
echo "status: $status"
assert_success
assert_output_contains "No changes detected, skipping git commit" 0
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "dokku-test/$TEST_APP:default-procfile"
}

View File

@@ -154,6 +154,12 @@ teardown() {
assert_failure
assert_output "fatal: this operation must be run in a work tree"
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output ""
run /bin/bash -c "dokku config:set --no-restart $TEST_APP FAIL_ON_STARTUP=false"
echo "output: $output"
echo "status: $status"
@@ -164,6 +170,12 @@ teardown() {
echo "status: $status"
assert_success
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "dokku-test/$TEST_APP:latest"
run /bin/bash -c "dokku config:set --no-restart $TEST_APP FAIL_ON_STARTUP=true"
echo "output: $output"
echo "status: $status"
@@ -174,6 +186,12 @@ teardown() {
echo "status: $status"
assert_failure
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "dokku-test/$TEST_APP:latest"
run /bin/bash -c "dokku config:set --no-restart $TEST_APP FAIL_ON_STARTUP=false"
echo "output: $output"
echo "status: $status"
@@ -184,6 +202,12 @@ teardown() {
echo "status: $status"
assert_success
assert_output_contains "No changes detected, skipping git commit" 0
run /bin/bash -c "dokku git:report $TEST_APP --git-source-image"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "dokku-test/$TEST_APP:v2"
}
@test "(git) git:load-image [onbuild]" {