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
56 changes: 47 additions & 9 deletions lib/kamal/cli/main.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
45 changes: 45 additions & 0 deletions test/cli/main_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down