diff --git a/docs/deployment/methods/git.md b/docs/deployment/methods/git.md index c7290bf5a..961ae075b 100644 --- a/docs/deployment/methods/git.md +++ b/docs/deployment/methods/git.md @@ -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] diff --git a/docs/deployment/methods/image.md b/docs/deployment/methods/image.md index bc180a9f7..7db846041 100644 --- a/docs/deployment/methods/image.md +++ b/docs/deployment/methods/image.md @@ -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] diff --git a/plugins/git/functions b/plugins/git/functions index ea1a06bb7..5886822bf 100755 --- a/plugins/git/functions +++ b/plugins/git/functions @@ -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 diff --git a/plugins/git/git-from-archive b/plugins/git/git-from-archive index 3fe833fee..b2788f464 100755 --- a/plugins/git/git-from-archive +++ b/plugins/git/git-from-archive @@ -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 "$@" diff --git a/plugins/git/git-from-directory b/plugins/git/git-from-directory index 87dddadd3..542090c41 100755 --- a/plugins/git/git-from-directory +++ b/plugins/git/git-from-directory @@ -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=$? diff --git a/plugins/git/git-from-image b/plugins/git/git-from-image index 52109ba88..22c0d3cca 100755 --- a/plugins/git/git-from-image +++ b/plugins/git/git-from-image @@ -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 "$@" diff --git a/plugins/git/internal-functions b/plugins/git/internal-functions index fa6455c4a..f841499e9 100644 --- a/plugins/git/internal-functions +++ b/plugins/git/internal-functions @@ -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")" diff --git a/tests/unit/git_4.bats b/tests/unit/git_4.bats index 3abdc460b..e29126095 100644 --- a/tests/unit/git_4.bats +++ b/tests/unit/git_4.bats @@ -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" +} diff --git a/tests/unit/git_6.bats b/tests/unit/git_6.bats index 03c81f264..59028d573 100644 --- a/tests/unit/git_6.bats +++ b/tests/unit/git_6.bats @@ -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]" {