Compare commits

..

4 Commits

Author SHA1 Message Date
Donal McBreen
015c5a6f90 Add kamal proxy update
For easy updates from Traefik to kamal-proxy, add `kamal proxy update`.

This stops and removes Traefik and kamal-proxy containers (just in case
it is run after an update). Then it starts kamal-proxy and calls
`kamal-proxy deploy` to route to any app containers that are already
running.

It can be run with a rolling option and calls the `pre-proxy-reboot` and
`post-proxy-reboot` hooks for each host.
2024-05-28 15:31:56 +01:00
Donal McBreen
6568cef868 Replace Traefik with kamal-proxy
[kamal-proxy](https://github.com/basecamp/kamal-proxy) is a custom
minimal proxy designed specifically for Kamal.

It has some advantages over Traefik:
1. Imperative deployments - we tell it to switch from container A to
   container B, and it waits for container B to start then switches. No
   need to poll for health checks ourselves or mess around with forcing
   health checks to fail.
2. Support for multiple apps - as much as possible, configuration is
   supplied at runtime by the deploy command, allowing us to have
   multiple apps share a proxy without conflicting config.
3. First class support for Kamal operations - rather than trying to
   work out how to make Traefik do what we want, we can build features
   directly into the proxy, making configuration simpler and avoiding
   obscure errors
2024-05-28 15:31:56 +01:00
Donal McBreen
90ecb6a12a Merge pull request #821 from basecamp/retry-clone
Handle corrupt git clones
2024-05-28 15:23:36 +01:00
Donal McBreen
2c2053558a Handle corrupt git clones
When cloning the git repo:
1. Try to clone
2. If there's already a build directory reset it
3. Check the clone is valid

If anything goes wrong during that process:
1. Delete the clone directory
2. Clone it again
3. Check the clone is valid

Raise any errors after that
2024-05-27 11:17:34 +01:00
18 changed files with 307 additions and 124 deletions

View File

@@ -1,6 +1,6 @@
# Kamal: Deploy web apps anywhere # Kamal: Deploy web apps anywhere
From bare metal to cloud VMs, deploy web apps anywhere with zero downtime. Kamal uses parachute for zero-downtime deployments. Works seamlessly across multiple hosts, using SSHKit to execute commands. Originally built for Rails apps, Kamal will work with any type of web app that can be containerized with Docker. From bare metal to cloud VMs, deploy web apps anywhere with zero downtime. Kamal uses a [custom proxy](https://github.com/basecamp/kamal-proxy) for zero-downtime deployments. Works seamlessly across multiple hosts, using SSHKit to execute commands. Originally built for Rails apps, Kamal will work with any type of web app that can be containerized with Docker.
➡️ See [kamal-deploy.org](https://kamal-deploy.org) for documentation on [installation](https://kamal-deploy.org/docs/installation), [configuration](https://kamal-deploy.org/docs/configuration), and [commands](https://kamal-deploy.org/docs/commands). ➡️ See [kamal-deploy.org](https://kamal-deploy.org) for documentation on [installation](https://kamal-deploy.org/docs/installation), [configuration](https://kamal-deploy.org/docs/configuration), and [commands](https://kamal-deploy.org/docs/commands).

View File

@@ -23,7 +23,9 @@ class Kamal::Cli::Build < Kamal::Cli::Base
say "Building from a local git clone, so ignoring these uncommitted changes:\n #{uncommitted_changes}", :yellow say "Building from a local git clone, so ignoring these uncommitted changes:\n #{uncommitted_changes}", :yellow
end end
prepare_clone run_locally do
Clone.new(self).prepare
end
elsif uncommitted_changes.present? elsif uncommitted_changes.present?
say "Building with uncommitted changes:\n #{uncommitted_changes}", :yellow say "Building with uncommitted changes:\n #{uncommitted_changes}", :yellow
end end
@@ -126,23 +128,4 @@ class Kamal::Cli::Build < Kamal::Cli::Base
end end
end end
end end
def prepare_clone
run_locally do
begin
info "Cloning repo into build directory `#{KAMAL.config.builder.build_directory}`..."
execute *KAMAL.builder.create_clone_directory
execute *KAMAL.builder.clone
rescue SSHKit::Command::Failed => e
if e.message =~ /already exists and is not an empty directory/
info "Resetting local clone as `#{KAMAL.config.builder.build_directory}` already exists..."
KAMAL.builder.clone_reset_steps.each { |step| execute *step }
else
raise
end
end
end
end
end end

View File

@@ -0,0 +1,61 @@
require "uri"
class Kamal::Cli::Build::Clone
attr_reader :sshkit
delegate :info, :error, :execute, :capture_with_info, to: :sshkit
def initialize(sshkit)
@sshkit = sshkit
end
def prepare
begin
clone_repo
rescue SSHKit::Command::Failed => e
if e.message =~ /already exists and is not an empty directory/
reset
else
raise Kamal::Cli::Build::BuildError, "Failed to clone repo: #{e.message}"
end
end
validate!
rescue Kamal::Cli::Build::BuildError => e
error "Error preparing clone: #{e.message}, deleting and retrying..."
FileUtils.rm_rf KAMAL.config.builder.clone_directory
clone_repo
validate!
end
private
def clone_repo
info "Cloning repo into build directory `#{KAMAL.config.builder.build_directory}`..."
FileUtils.mkdir_p KAMAL.config.builder.clone_directory
execute *KAMAL.builder.clone
end
def reset
info "Resetting local clone as `#{KAMAL.config.builder.build_directory}` already exists..."
KAMAL.builder.clone_reset_steps.each { |step| execute *step }
rescue SSHKit::Command::Failed => e
raise Kamal::Cli::Build::BuildError, "Failed to clone repo: #{e.message}"
end
def validate!
status = capture_with_info(*KAMAL.builder.clone_status).strip
unless status.empty?
raise Kamal::Cli::Build::BuildError, "Clone in #{KAMAL.config.builder.build_directory} is dirty, #{status}"
end
revision = capture_with_info(*KAMAL.builder.clone_revision).strip
if revision != Kamal::Git.revision
raise Kamal::Cli::Build::BuildError, "Clone in #{KAMAL.config.builder.build_directory} is not on the correct revision, expected `#{Kamal::Git.revision}` but got `#{revision}`"
end
rescue SSHKit::Command::Failed => e
raise Kamal::Cli::Build::BuildError, "Failed to validate clone: #{e.message}"
end
end

View File

@@ -60,6 +60,50 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
end end
end end
desc "update", "Update from Traefik to kamal-proxy, for when moving from Kamal v1 to Kamal v2"
option :rolling, type: :boolean, default: false, desc: "Reboot proxy on hosts in sequence, rather than in parallel"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def update
confirming "This will cause a brief outage on each host. Are you sure?" do
with_lock do
host_groups = options[:rolling] ? KAMAL.proxy_hosts : [ KAMAL.proxy_hosts ]
host_groups.each do |hosts|
host_list = Array(hosts).join(",")
run_hook "pre-proxy-reboot", hosts: host_list
on(hosts) do
info "Updating proxy from Traefik to kamal-proxy on #{host}..."
execute *KAMAL.auditor.record("Updated proxy from Traefik to kamal-proxy"), verbosity: :debug
execute *KAMAL.registry.login
info "Stopping and removing Traefik on #{host}..."
execute *KAMAL.proxy.stop(name: "traefik"), raise_on_non_zero_exit: false
execute *KAMAL.proxy.remove_container(filter: "label=org.opencontainers.image.title=traefik")
execute *KAMAL.proxy.remove_image(filter: "label=org.opencontainers.image.title=traefik")
info "Stopping and removing kamal-proxy on #{host}, if running..."
execute *KAMAL.proxy.stop, raise_on_non_zero_exit: false
execute *KAMAL.proxy.remove_container
info "Starting kamal-proxy on #{host}..."
execute *KAMAL.proxy.run
KAMAL.roles_on(host).select(&:running_proxy?).each do |role|
app = KAMAL.app(role: role, host: host)
version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
endpoint = capture_with_info(*app.container_endpoint(version: version)).strip
raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, is the app container running?" if endpoint.empty?
info "Deploying #{endpoint} for role `#{role}` on #{host}..."
execute *KAMAL.proxy.deploy(role.container_prefix, target: endpoint)
end
end
run_hook "post-proxy-reboot", hosts: host_list
end
end
end
end
desc "details", "Show details about proxy container from servers" desc "details", "Show details about proxy container from servers"
def details def details
on(KAMAL.proxy_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.proxy.info), type: "Proxy" } on(KAMAL.proxy_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.proxy.info), type: "Proxy" }

View File

@@ -2,7 +2,8 @@ require "active_support/core_ext/string/filters"
class Kamal::Commands::Builder < Kamal::Commands::Base class Kamal::Commands::Builder < Kamal::Commands::Base
delegate :create, :remove, :push, :clean, :pull, :info, :validate_image, to: :target delegate :create, :remove, :push, :clean, :pull, :info, :validate_image, to: :target
delegate :clone_directory, :build_directory, to: :"config.builder"
include Clone
def name def name
target.class.to_s.remove("Kamal::Commands::Builder::").underscore.inquiry target.class.to_s.remove("Kamal::Commands::Builder::").underscore.inquiry
@@ -54,23 +55,6 @@ class Kamal::Commands::Builder < Kamal::Commands::Base
end end
end end
def create_clone_directory
make_directory clone_directory
end
def clone
git :clone, Kamal::Git.root, path: clone_directory
end
def clone_reset_steps
[
git(:remote, "set-url", :origin, Kamal::Git.root, path: build_directory),
git(:fetch, :origin, path: build_directory),
git(:reset, "--hard", Kamal::Git.revision, path: build_directory),
git(:clean, "-fdx", path: build_directory)
]
end
private private
def ensure_local_docker_installed def ensure_local_docker_installed
docker "--version" docker "--version"

View File

@@ -0,0 +1,28 @@
module Kamal::Commands::Builder::Clone
extend ActiveSupport::Concern
included do
delegate :clone_directory, :build_directory, to: :"config.builder"
end
def clone
git :clone, Kamal::Git.root, path: clone_directory
end
def clone_reset_steps
[
git(:remote, "set-url", :origin, Kamal::Git.root, path: build_directory),
git(:fetch, :origin, path: build_directory),
git(:reset, "--hard", Kamal::Git.revision, path: build_directory),
git(:clean, "-fdx", path: build_directory)
]
end
def clone_status
git :status, "--porcelain", path: build_directory
end
def clone_revision
git :"rev-parse", :HEAD, path: build_directory
end
end

View File

@@ -16,7 +16,7 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base
"--restart", "unless-stopped", "--restart", "unless-stopped",
*proxy_config.publish_args, *proxy_config.publish_args,
"--volume", "/var/run/docker.sock:/var/run/docker.sock", "--volume", "/var/run/docker.sock:/var/run/docker.sock",
"--volume", "#{container_name}:/root/.config/parachute", "--volume", "#{container_name}:/root/.config/kamal-proxy",
*config.logging_args, *config.logging_args,
*proxy_config.docker_options_args, *proxy_config.docker_options_args,
proxy_config.image proxy_config.image
@@ -26,8 +26,8 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base
docker :container, :start, container_name docker :container, :start, container_name
end end
def stop def stop(name: container_name)
docker :container, :stop, container_name docker :container, :stop, name
end end
def start_or_run def start_or_run
@@ -36,11 +36,11 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base
def deploy(service, target:) def deploy(service, target:)
optionize({ target: target }) optionize({ target: target })
docker :exec, container_name, :parachute, :deploy, service, *optionize({ target: target }), *proxy_config.deploy_command_args docker :exec, container_name, "kamal-proxy", :deploy, service, *optionize({ target: target }), *proxy_config.deploy_command_args
end end
def remove(service, target:) def remove(service, target:)
docker :exec, container_name, :parachute, :remove, service, *optionize({ target: target }) docker :exec, container_name, "kamal-proxy", :remove, service, *optionize({ target: target })
end end
def info def info
@@ -60,20 +60,20 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base
).join(" "), host: host ).join(" "), host: host
end end
def remove_container def remove_container(filter: container_filter)
docker :container, :prune, "--force", "--filter", container_filter docker :container, :prune, "--force", "--filter", filter
end end
def remove_image def remove_image(filter: image_filter)
docker :image, :prune, "--all", "--force", "--filter", image_filter docker :image, :prune, "--all", "--force", "--filter", filter
end end
private private
def container_filter def container_filter
"label=org.opencontainers.image.title=parachute" "label=org.opencontainers.image.title=kamal-proxy"
end end
def image_filter def image_filter
"label=org.opencontainers.image.title=parachute" "label=org.opencontainers.image.title=kamal-proxy"
end end
end end

View File

@@ -1,7 +1,7 @@
class Kamal::Configuration::Proxy class Kamal::Configuration::Proxy
DEFAULT_HTTP_PORT = 80 DEFAULT_HTTP_PORT = 80
DEFAULT_HTTPS_PORT = 443 DEFAULT_HTTPS_PORT = 443
DEFAULT_IMAGE = "basecamp/parachute:latest" DEFAULT_IMAGE = "basecamp/kamal-proxy:latest"
delegate :argumentize, :optionize, to: Kamal::Utils delegate :argumentize, :optionize, to: Kamal::Utils
@@ -18,23 +18,15 @@ class Kamal::Configuration::Proxy
end end
def http_port def http_port
if options.key?(:http_port) options.fetch(:http_port, DEFAULT_HTTP_PORT)
options[:http_port]
elsif !automatic_tls?
DEFAULT_HTTP_PORT
end
end end
def https_port def https_port
if options.key?(:http_port) options.fetch(:http_port, DEFAULT_HTTPS_PORT)
options[:http_port]
elsif automatic_tls?
DEFAULT_HTTPS_PORT
end
end end
def container_name def container_name
"parachute_#{http_port}_#{https_port}" "kamal-proxy"
end end
def docker_options_args def docker_options_args
@@ -42,7 +34,7 @@ class Kamal::Configuration::Proxy
end end
def publish_args def publish_args
argumentize "--publish", *("#{http_port}:80" if http_port), *("#{https_port}:80" if https_port) argumentize "--publish", [ *("#{http_port}:#{DEFAULT_HTTP_PORT}" if http_port), *("#{https_port}:#{DEFAULT_HTTPS_PORT}" if https_port) ]
end end
def deploy_options def deploy_options

View File

@@ -137,7 +137,7 @@ class CliAppTest < CliTestCase
SSHKit::Backend::Abstract.any_instance.stubs(:execute).returns("") SSHKit::Backend::Abstract.any_instance.stubs(:execute).returns("")
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, :exec, "parachute_80_", :parachute, :deploy, "app-web", "--target", "\"172.1.0.2:80\"").raises(SSHKit::Command::Failed, "Deploy failed").at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, :exec, "kamal-proxy", "kamal-proxy", :deploy, "app-web", "--target", "\"172.1.0.2:80\"").raises(SSHKit::Command::Failed, "Deploy failed").at_least_once
stderred do stderred do
run_command("boot", config: :with_roles, host: nil, allowed_error_message: "Deploy failed").tap do |output| run_command("boot", config: :with_roles, host: nil, allowed_error_message: "Deploy failed").tap do |output|

View File

@@ -9,10 +9,18 @@ class CliBuildTest < CliTestCase
end end
test "push" do test "push" do
with_build_directory do with_build_directory do |build_directory|
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "build", subcommand: "push" } hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "build", subcommand: "push" }
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD)
.returns(Kamal::Git.revision)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :status, "--porcelain")
.returns("")
run_command("push", "--verbose").tap do |output| run_command("push", "--verbose").tap do |output|
assert_hook_ran "pre-build", output, **hook_variables assert_hook_ran "pre-build", output, **hook_variables
assert_match /Cloning repo into build directory/, output assert_match /Cloning repo into build directory/, output
@@ -23,28 +31,33 @@ class CliBuildTest < CliTestCase
end end
end end
test "push reseting clone" do test "push resetting clone" do
with_build_directory do with_build_directory do |build_directory|
stub_setup stub_setup
build_dir = "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}/kamal/"
SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:docker, "--version", "&&", :docker, :buildx, "version") SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "--version", "&&", :docker, :buildx, "version")
SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:docker, :buildx, :create, "--use", "--name", "kamal-app-multiarch")
SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:mkdir, "-p", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}") SSHKit::Backend::Abstract.any_instance.expects(:execute)
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:git, "-C", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}", :clone, Dir.pwd) .with(:git, "-C", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}", :clone, Dir.pwd)
.raises(SSHKit::Command::Failed.new("fatal: destination path 'kamal' already exists and is not an empty directory")) .raises(SSHKit::Command::Failed.new("fatal: destination path 'kamal' already exists and is not an empty directory"))
.then .then
.returns(true) .returns(true)
SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:git, "-C", build_dir, :remote, "set-url", :origin, Dir.pwd) SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :remote, "set-url", :origin, Dir.pwd)
SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:git, "-C", build_dir, :fetch, :origin) SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :fetch, :origin)
SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:git, "-C", build_dir, :reset, "--hard", Kamal::Git.revision) SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :reset, "--hard", Kamal::Git.revision)
SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:git, "-C", build_dir, :clean, "-fdx") SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :clean, "-fdx")
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64,linux/arm64", "--builder", "kamal-app-multiarch", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".") .with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64,linux/arm64", "--builder", "kamal-app-multiarch", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD)
.returns(Kamal::Git.revision)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :status, "--porcelain")
.returns("")
run_command("push", "--verbose").tap do |output| run_command("push", "--verbose").tap do |output|
assert_match /Cloning repo into build directory/, output assert_match /Cloning repo into build directory/, output
assert_match /Resetting local clone/, output assert_match /Resetting local clone/, output
@@ -64,25 +77,65 @@ class CliBuildTest < CliTestCase
end end
end end
test "push without builder" do test "push with corrupt clone" do
with_build_directory do with_build_directory do |build_directory|
stub_setup stub_setup
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "--version", "&&", :docker, :buildx, "version")
SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:git, "-C", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}", :clone, Dir.pwd)
.raises(SSHKit::Command::Failed.new("fatal: destination path 'kamal' already exists and is not an empty directory"))
.then
.returns(true)
.twice
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :remote, "set-url", :origin, Dir.pwd)
.raises(SSHKit::Command::Failed.new("fatal: not a git repository"))
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD)
.returns(Kamal::Git.revision)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :status, "--porcelain")
.returns("")
Dir.stubs(:chdir)
run_command("push", "--verbose") do |output|
assert_match /Cloning repo into build directory `#{build_directory}`\.\.\..*Cloning repo into build directory `#{build_directory}`\.\.\./, output
assert_match "Resetting local clone as `#{build_directory}` already exists...", output
assert_match "Error preparing clone: Failed to clone repo: fatal: not a git repository, deleting and retrying...", output
end
end
end
test "push without builder" do
with_build_directory do |build_directory|
stub_setup
SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, "--version", "&&", :docker, :buildx, "version") .with(:docker, "--version", "&&", :docker, :buildx, "version")
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :create, "--use", "--name", "kamal-app-multiarch") .with(:docker, :buildx, :create, "--use", "--name", "kamal-app-multiarch")
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with { |*args| args[0..1] == [ :docker, :buildx ] } .with { |*args| args[0..1] == [ :docker, :buildx ] }
.raises(SSHKit::Command::Failed.new("no builder")) .raises(SSHKit::Command::Failed.new("no builder"))
.then .then
.returns(true) .returns(true)
SSHKit::Backend::Abstract.any_instance.stubs(:execute).with { |*args| args.first.start_with?("git") } SSHKit::Backend::Abstract.any_instance.expects(:execute).with { |*args| args.first.start_with?("git") }
SSHKit::Backend::Abstract.any_instance.stubs(:execute).with(:mkdir, "-p", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD)
.returns(Kamal::Git.revision)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :status, "--porcelain")
.returns("")
run_command("push").tap do |output| run_command("push").tap do |output|
assert_match /WARN Missing compatible builder, so creating a new one first/, output assert_match /WARN Missing compatible builder, so creating a new one first/, output
@@ -183,7 +236,7 @@ class CliBuildTest < CliTestCase
build_directory = File.join Dir.tmpdir, "kamal-clones", "app-#{pwd_sha}", "kamal" build_directory = File.join Dir.tmpdir, "kamal-clones", "app-#{pwd_sha}", "kamal"
FileUtils.mkdir_p build_directory FileUtils.mkdir_p build_directory
FileUtils.touch File.join build_directory, "Dockerfile" FileUtils.touch File.join build_directory, "Dockerfile"
yield yield build_directory + "/"
ensure ensure
FileUtils.rm_rf build_directory FileUtils.rm_rf build_directory
end end

View File

@@ -108,6 +108,14 @@ class CliMainTest < CliTestCase
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_debug) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_debug)
.with(:stat, ".kamal/locks/app", ">", "/dev/null", "&&", :cat, ".kamal/locks/app/details", "|", :base64, "-d") .with(:stat, ".kamal/locks/app", ">", "/dev/null", "&&", :cat, ".kamal/locks/app/details", "|", :base64, "-d")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD)
.returns(Kamal::Git.revision)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :status, "--porcelain")
.returns("")
assert_raises(Kamal::Cli::LockError) do assert_raises(Kamal::Cli::LockError) do
run_command("deploy") run_command("deploy")
end end
@@ -129,6 +137,14 @@ class CliMainTest < CliTestCase
.with { |*arg| arg[0..1] == [ :mkdir, ".kamal/locks/app" ] } .with { |*arg| arg[0..1] == [ :mkdir, ".kamal/locks/app" ] }
.raises(SocketError, "getaddrinfo: nodename nor servname provided, or not known") .raises(SocketError, "getaddrinfo: nodename nor servname provided, or not known")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD)
.returns(Kamal::Git.revision)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :status, "--porcelain")
.returns("")
assert_raises(SSHKit::Runner::ExecuteError) do assert_raises(SSHKit::Runner::ExecuteError) do
run_command("deploy") run_command("deploy")
end end
@@ -437,9 +453,9 @@ class CliMainTest < CliTestCase
test "remove with confirmation" do test "remove with confirmation" do
run_command("remove", "-y", config_file: "deploy_with_accessories").tap do |output| run_command("remove", "-y", config_file: "deploy_with_accessories").tap do |output|
assert_match /docker container stop parachute/, output assert_match /docker container stop kamal-proxy/, output
assert_match /docker container prune --force --filter label=org.opencontainers.image.title=parachute/, output assert_match /docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy/, output
assert_match /docker image prune --all --force --filter label=org.opencontainers.image.title=parachute/, output assert_match /docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy/, output
assert_match /docker ps --quiet --filter label=service=app | xargs docker stop/, output assert_match /docker ps --quiet --filter label=service=app | xargs docker stop/, output
assert_match /docker container prune --force --filter label=service=app/, output assert_match /docker container prune --force --filter label=service=app/, output

View File

@@ -4,7 +4,7 @@ class CliProxyTest < CliTestCase
test "boot" do test "boot" do
run_command("boot").tap do |output| run_command("boot").tap do |output|
assert_match "docker login", output assert_match "docker login", output
assert_match "docker run --name parachute_80_ --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume parachute_80_:/root/.config/parachute --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}", output assert_match "docker run --name kamal-proxy --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume kamal-proxy:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}", output
end end
end end
@@ -12,27 +12,27 @@ class CliProxyTest < CliTestCase
Kamal::Commands::Registry.any_instance.expects(:login).twice Kamal::Commands::Registry.any_instance.expects(:login).twice
run_command("reboot", "-y").tap do |output| run_command("reboot", "-y").tap do |output|
assert_match "docker container stop parachute", output assert_match "docker container stop kamal-proxy", output
assert_match "docker container prune --force --filter label=org.opencontainers.image.title=parachute", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy", output
assert_match "docker run --name parachute_80_ --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume parachute_80_:/root/.config/parachute --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}", output assert_match "docker run --name kamal-proxy --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume kamal-proxy:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}", output
end end
end end
test "reboot --rolling" do test "reboot --rolling" do
run_command("reboot", "--rolling", "-y").tap do |output| run_command("reboot", "--rolling", "-y").tap do |output|
assert_match "Running docker container prune --force --filter label=org.opencontainers.image.title=parachute on 1.1.1.1", output assert_match "Running docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.1", output
end end
end end
test "start" do test "start" do
run_command("start").tap do |output| run_command("start").tap do |output|
assert_match "docker container start parachute", output assert_match "docker container start kamal-proxy", output
end end
end end
test "stop" do test "stop" do
run_command("stop").tap do |output| run_command("stop").tap do |output|
assert_match "docker container stop parachute", output assert_match "docker container stop kamal-proxy", output
end end
end end
@@ -45,13 +45,13 @@ class CliProxyTest < CliTestCase
test "details" do test "details" do
run_command("details").tap do |output| run_command("details").tap do |output|
assert_match "docker ps --filter name=^parachute_80_$", output assert_match "docker ps --filter name=^kamal-proxy$", output
end end
end end
test "logs" do test "logs" do
SSHKit::Backend::Abstract.any_instance.stubs(:capture) SSHKit::Backend::Abstract.any_instance.stubs(:capture)
.with(:docker, :logs, "parachute_80_", " --tail 100", "--timestamps", "2>&1") .with(:docker, :logs, "kamal-proxy", " --tail 100", "--timestamps", "2>&1")
.returns("Log entry") .returns("Log entry")
run_command("logs").tap do |output| run_command("logs").tap do |output|
@@ -62,9 +62,9 @@ class CliProxyTest < CliTestCase
test "logs with follow" do test "logs with follow" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec) SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.1 -p 22 'docker logs parachute_80_ --timestamps --tail 10 --follow 2>&1'") .with("ssh -t root@1.1.1.1 -p 22 'docker logs kamal-proxy --timestamps --tail 10 --follow 2>&1'")
assert_match "docker logs parachute_80_ --timestamps --tail 10 --follow", run_command("logs", "--follow") assert_match "docker logs kamal-proxy --timestamps --tail 10 --follow", run_command("logs", "--follow")
end end
test "remove" do test "remove" do
@@ -77,13 +77,35 @@ class CliProxyTest < CliTestCase
test "remove_container" do test "remove_container" do
run_command("remove_container").tap do |output| run_command("remove_container").tap do |output|
assert_match "docker container prune --force --filter label=org.opencontainers.image.title=parachute", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy", output
end end
end end
test "remove_image" do test "remove_image" do
run_command("remove_image").tap do |output| run_command("remove_image").tap do |output|
assert_match "docker image prune --all --force --filter label=org.opencontainers.image.title=parachute", output assert_match "docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy", output
end
end
test "update" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{.NetworkSettings.IPAddress}}{{range $k, $v := .NetworkSettings.Ports}}{{printf \":%s\" $k}}{{break}}{{end}}'", "|", :sed, "-e", "'s/\\/tcp$//'")
.returns("172.1.0.2:80")
.at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with { |*args| args[0..1] == [ :sh, "-c" ] }
.returns("123")
.at_least_once
run_command("update", "-y").tap do |output|
assert_match "docker container stop traefik", output
assert_match "docker container prune --force --filter label=org.opencontainers.image.title=traefik", output
assert_match "docker image prune --all --force --filter label=org.opencontainers.image.title=traefik", output
assert_match "docker container stop kamal-proxy", output
assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy", output
assert_match "docker run --name kamal-proxy --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume kamal-proxy:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}", output
assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target \"172.1.0.2:80\"", output
end end
end end

View File

@@ -15,13 +15,13 @@ class CommandsProxyTest < ActiveSupport::TestCase
test "run" do test "run" do
assert_equal \ assert_equal \
"docker run --name parachute_80_ --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume parachute_80_:/root/.config/parachute --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}", "docker run --name kamal-proxy --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume kamal-proxy:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}",
new_command.run.join(" ") new_command.run.join(" ")
end end
test "run with ports configured" do test "run with ports configured" do
assert_equal \ assert_equal \
"docker run --name parachute_80_ --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume parachute_80_:/root/.config/parachute --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}", "docker run --name kamal-proxy --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume kamal-proxy:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -29,7 +29,7 @@ class CommandsProxyTest < ActiveSupport::TestCase
@config.delete(:proxy) @config.delete(:proxy)
assert_equal \ assert_equal \
"docker run --name parachute_80_ --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume parachute_80_:/root/.config/parachute --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}", "docker run --name kamal-proxy --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume kamal-proxy:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -37,85 +37,85 @@ class CommandsProxyTest < ActiveSupport::TestCase
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } }
assert_equal \ assert_equal \
"docker run --name parachute_80_ --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume parachute_80_:/root/.config/parachute --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}", "docker run --name kamal-proxy --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume kamal-proxy:/root/.config/kamal-proxy --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}",
new_command.run.join(" ") new_command.run.join(" ")
end end
test "proxy start" do test "proxy start" do
assert_equal \ assert_equal \
"docker container start parachute_80_", "docker container start kamal-proxy",
new_command.start.join(" ") new_command.start.join(" ")
end end
test "proxy stop" do test "proxy stop" do
assert_equal \ assert_equal \
"docker container stop parachute_80_", "docker container stop kamal-proxy",
new_command.stop.join(" ") new_command.stop.join(" ")
end end
test "proxy info" do test "proxy info" do
assert_equal \ assert_equal \
"docker ps --filter name=^parachute_80_$", "docker ps --filter name=^kamal-proxy$",
new_command.info.join(" ") new_command.info.join(" ")
end end
test "proxy logs" do test "proxy logs" do
assert_equal \ assert_equal \
"docker logs parachute_80_ --timestamps 2>&1", "docker logs kamal-proxy --timestamps 2>&1",
new_command.logs.join(" ") new_command.logs.join(" ")
end end
test "proxy logs since 2h" do test "proxy logs since 2h" do
assert_equal \ assert_equal \
"docker logs parachute_80_ --since 2h --timestamps 2>&1", "docker logs kamal-proxy --since 2h --timestamps 2>&1",
new_command.logs(since: "2h").join(" ") new_command.logs(since: "2h").join(" ")
end end
test "proxy logs last 10 lines" do test "proxy logs last 10 lines" do
assert_equal \ assert_equal \
"docker logs parachute_80_ --tail 10 --timestamps 2>&1", "docker logs kamal-proxy --tail 10 --timestamps 2>&1",
new_command.logs(lines: 10).join(" ") new_command.logs(lines: 10).join(" ")
end end
test "proxy logs with grep hello!" do test "proxy logs with grep hello!" do
assert_equal \ assert_equal \
"docker logs parachute_80_ --timestamps 2>&1 | grep 'hello!'", "docker logs kamal-proxy --timestamps 2>&1 | grep 'hello!'",
new_command.logs(grep: "hello!").join(" ") new_command.logs(grep: "hello!").join(" ")
end end
test "proxy remove container" do test "proxy remove container" do
assert_equal \ assert_equal \
"docker container prune --force --filter label=org.opencontainers.image.title=parachute", "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy",
new_command.remove_container.join(" ") new_command.remove_container.join(" ")
end end
test "proxy remove image" do test "proxy remove image" do
assert_equal \ assert_equal \
"docker image prune --all --force --filter label=org.opencontainers.image.title=parachute", "docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy",
new_command.remove_image.join(" ") new_command.remove_image.join(" ")
end end
test "proxy follow logs" do test "proxy follow logs" do
assert_equal \ assert_equal \
"ssh -t root@1.1.1.1 -p 22 'docker logs parachute_80_ --timestamps --tail 10 --follow 2>&1'", "ssh -t root@1.1.1.1 -p 22 'docker logs kamal-proxy --timestamps --tail 10 --follow 2>&1'",
new_command.follow_logs(host: @config[:servers].first) new_command.follow_logs(host: @config[:servers].first)
end end
test "proxy follow logs with grep hello!" do test "proxy follow logs with grep hello!" do
assert_equal \ assert_equal \
"ssh -t root@1.1.1.1 -p 22 'docker logs parachute_80_ --timestamps --tail 10 --follow 2>&1 | grep \"hello!\"'", "ssh -t root@1.1.1.1 -p 22 'docker logs kamal-proxy --timestamps --tail 10 --follow 2>&1 | grep \"hello!\"'",
new_command.follow_logs(host: @config[:servers].first, grep: "hello!") new_command.follow_logs(host: @config[:servers].first, grep: "hello!")
end end
test "deploy" do test "deploy" do
assert_equal \ assert_equal \
"docker exec parachute_80_ parachute deploy service --target \"172.1.0.2:80\"", "docker exec kamal-proxy kamal-proxy deploy service --target \"172.1.0.2:80\"",
new_command.deploy("service", target: "172.1.0.2:80").join(" ") new_command.deploy("service", target: "172.1.0.2:80").join(" ")
end end
test "remove" do test "remove" do
assert_equal \ assert_equal \
"docker exec parachute_80_ parachute remove service --target \"172.1.0.2:80\"", "docker exec kamal-proxy kamal-proxy remove service --target \"172.1.0.2:80\"",
new_command.remove("service", target: "172.1.0.2:80").join(" ") new_command.remove("service", target: "172.1.0.2:80").join(" ")
end end

View File

@@ -27,7 +27,7 @@ builder:
args: args:
COMMIT_SHA: <%= `git rev-parse HEAD` %> COMMIT_SHA: <%= `git rev-parse HEAD` %>
proxy: proxy:
image: registry:4443/basecamp/parachute:latest image: registry:4443/basecamp/kamal-proxy:latest
http_port: 80 http_port: 80
https_port: 443 https_port: 443
debug: true debug: true

View File

@@ -21,7 +21,7 @@ builder:
args: args:
COMMIT_SHA: <%= `git rev-parse HEAD` %> COMMIT_SHA: <%= `git rev-parse HEAD` %>
proxy: proxy:
image: registry:4443/basecamp/parachute:latest image: registry:4443/basecamp/kamal-proxy:latest
accessories: accessories:
busybox: busybox:
service: custom-busybox service: custom-busybox

View File

@@ -19,7 +19,7 @@ push_image_to_registry_4443() {
install_kamal install_kamal
push_image_to_registry_4443 nginx 1-alpine-slim push_image_to_registry_4443 nginx 1-alpine-slim
push_image_to_registry_4443 basecamp/parachute latest push_image_to_registry_4443 basecamp/kamal-proxy latest
push_image_to_registry_4443 busybox 1.36.0 push_image_to_registry_4443 busybox 1.36.0
# .ssh is on a shared volume that persists between runs. Clean it up as the # .ssh is on a shared volume that persists between runs. Clean it up as the

View File

@@ -33,7 +33,7 @@ class IntegrationMainTest < IntegrationTest
assert_match /Proxy Host: vm2/, details assert_match /Proxy Host: vm2/, details
assert_match /App Host: vm1/, details assert_match /App Host: vm1/, details
assert_match /App Host: vm2/, details assert_match /App Host: vm2/, details
assert_match /basecamp\/parachute:latest/, details assert_match /basecamp\/kamal-proxy:latest/, details
assert_match /registry:4443\/app:#{first_version}/, details assert_match /registry:4443\/app:#{first_version}/, details
audit = kamal :audit, capture: true audit = kamal :audit, capture: true

View File

@@ -53,11 +53,11 @@ class IntegrationProxyTest < IntegrationTest
private private
def assert_proxy_running def assert_proxy_running
assert_match %r{registry:4443/basecamp/parachute:latest "parachute run"}, proxy_details assert_match %r{registry:4443/basecamp/kamal-proxy:latest "kamal-proxy run"}, proxy_details
end end
def assert_proxy_not_running def assert_proxy_not_running
assert_no_match %r{registry:4443/basecamp/parachute:latest "parachute run"}, proxy_details assert_no_match %r{registry:4443/basecamp/kamal-proxy:latest "kamal-proxy run"}, proxy_details
end end
def proxy_details def proxy_details