diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 93e0c884c..6f60062c3 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -80,22 +80,30 @@ def redeploy end desc "rollback [VERSION]", "Rollback app to VERSION" - def rollback(version) + def rollback(version = nil) rolled_back = false runtime = print_runtime do with_lock do invoke_options = deploy_options - KAMAL.config.version = version - old_version = nil + if version.to_s.strip.empty? + version = previous_container_version + unless version.present? + say "No previous version is available on all hosts — aborting automated rollback. Run `kamal app containers` to list available versions.", :red + end + end - if container_available?(version) - run_hook "pre-deploy", secrets: true + if version.present? + KAMAL.config.version = version - invoke "kamal:cli:app:boot", [], invoke_options.merge(version: version) - rolled_back = true - else - say "The app version '#{version}' is not available as a container (use 'kamal app containers' for available versions)", :red + if container_available?(version) + run_hook "pre-deploy", secrets: true + + invoke "kamal:cli:app:boot", [], invoke_options.merge(version: version) + rolled_back = true + else + say "The app version '#{version}' is not available as a container (use 'kamal app containers' for available versions)", :red + end end end end @@ -279,4 +287,34 @@ def deploy_options base_options = base_options.except("no_cache") unless base_options["no_cache"] { "version" => KAMAL.config.version }.merge(base_options) end + + def previous_container_version + versions_per_host_and_role = [] + + on(KAMAL.app_hosts) do |host| + KAMAL.roles_on(host).each do |role| + app = KAMAL.app(role: role, host: host) + + versions_raw = capture_with_info(*app.list_versions("--all"), raise_on_non_zero_exit: false).to_s + versions = versions_raw.split("\n").map(&:strip).reject(&:empty?) + + current_raw = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).to_s + current = current_raw.strip + + rollback_versions = versions - [ current ] + versions_per_host_and_role << rollback_versions + end + end + + return nil if versions_per_host_and_role.empty? + + intersection = versions_per_host_and_role.reduce { |a, b| a & b } || [] + return nil if intersection.empty? + + ordered_versions = versions_per_host_and_role.flatten.uniq + version = ordered_versions.find { |v| intersection.include?(v) } + version.presence + rescue SSHKit::Runner::ExecuteError, SSHKit::Runner::MultipleExecuteError + raise + end end diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 7aafae3c3..712334f98 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -377,6 +377,51 @@ class CliMainTest < CliTestCase end end + test "rollback uses previous_container_version when no version supplied" do + Object.any_instance.stubs(:sleep) + + Kamal::Cli::Main.any_instance.stubs(:previous_container_version).returns("123") + + [ "web", "workers" ].each do |role| + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet", raise_on_non_zero_exit: false) + .returns("").at_least_once + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet") + .returns("version-to-rollback\n").at_least_once + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=#{role} --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=#{role} --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-#{role}-}; done", raise_on_non_zero_exit: false) + .returns("version-to-rollback\n").at_least_once + end + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-workers-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") + .returns("running").at_least_once + + Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) + + run_command("rollback", "--verbose", config_file: "deploy_with_accessories").tap do |output| + assert_hook_ran "pre-deploy", output + assert_match "docker tag dhh/app:123 dhh/app:latest", output + assert_match "docker run --detach --restart unless-stopped --name app-web-123", output + assert_match "docker container ls --all --filter name=^app-web-version-to-rollback$ --quiet | xargs docker stop", output, "Should stop the container that was previously running" + assert_hook_ran "post-deploy", output + end + end + + test "rollback prints helpful message and returns when previous_container_version not found" do + Thread.report_on_exception = false + + Kamal::Cli::Main.any_instance.stubs(:previous_container_version).returns(nil) + + output = run_command("rollback", "--verbose") + + assert_match /No previous version is available on all hosts\s*—\s*aborting automated rollback\./i, output + assert_match /kamal app containers/, output, "Message should suggest `kamal app containers`" + assert_no_match /docker run --detach --restart unless-stopped/, output + end + + test "remove" do options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_hooks" => false, "confirmed" => true } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:remove", [], options)