Compare commits

..

4 Commits

Author SHA1 Message Date
Donal McBreen
c93588713b Set port 2024-03-28 15:43:08 +00:00
Donal McBreen
d47912572c WIP 2024-03-28 14:53:58 +00:00
Donal McBreen
00061ce7aa Use kamal network for containers
Create a `kamal` network for the proxy and app containers to run in.
This allows the proxy to refer to the app containers by name.
2024-03-28 14:07:24 +00:00
Donal McBreen
9c4747ec0c Replace Traefik with mproxy
[mproxy](https://github.com/kevinmcconnell/mproxy) is a custom minimal
proxy designed specifically for Kamal.

It has two big 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 an instance of mproxy without conflicting config.
2024-03-28 14:05:21 +00:00
96 changed files with 1124 additions and 1890 deletions

View File

@@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
kamal (1.5.2) kamal (1.4.0)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)
@@ -9,7 +9,7 @@ PATH
dotenv (~> 2.8) dotenv (~> 2.8)
ed25519 (~> 1.2) ed25519 (~> 1.2)
net-ssh (~> 7.0) net-ssh (~> 7.0)
sshkit (>= 1.22.2, < 2.0) sshkit (~> 1.21)
thor (~> 1.2) thor (~> 1.2)
zeitwerk (~> 2.5) zeitwerk (~> 2.5)
@@ -75,8 +75,6 @@ GEM
mutex_m (0.2.0) mutex_m (0.2.0)
net-scp (4.0.0) net-scp (4.0.0)
net-ssh (>= 2.6.5, < 8.0.0) net-ssh (>= 2.6.5, < 8.0.0)
net-sftp (4.0.0)
net-ssh (>= 5.0.0, < 8.0.0)
net-ssh (7.2.1) net-ssh (7.2.1)
nokogiri (1.16.0-arm64-darwin) nokogiri (1.16.0-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
@@ -153,11 +151,9 @@ GEM
rubocop-rails rubocop-rails
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
sshkit (1.22.2) sshkit (1.21.7)
base64
mutex_m mutex_m
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
stringio (3.1.0) stringio (3.1.0)
thor (1.3.0) thor (1.3.0)

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 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. From bare metal to cloud VMs, deploy web apps anywhere with zero downtime. Kamal uses mproxy 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

@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
spec.executables = %w[ kamal ] spec.executables = %w[ kamal ]
spec.add_dependency "activesupport", ">= 7.0" spec.add_dependency "activesupport", ">= 7.0"
spec.add_dependency "sshkit", ">= 1.22.2", "< 2.0" spec.add_dependency "sshkit", "~> 1.21"
spec.add_dependency "net-ssh", "~> 7.0" spec.add_dependency "net-ssh", "~> 7.0"
spec.add_dependency "thor", "~> 1.2" spec.add_dependency "thor", "~> 1.2"
spec.add_dependency "dotenv", "~> 2.8" spec.add_dependency "dotenv", "~> 2.8"

View File

@@ -5,6 +5,6 @@ require "active_support"
require "zeitwerk" require "zeitwerk"
loader = Zeitwerk::Loader.for_gem loader = Zeitwerk::Loader.for_gem
loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb")) loader.ignore("#{__dir__}/kamal/sshkit_with_ext.rb")
loader.setup loader.setup
loader.eager_load # We need all commands loaded. loader.eager_load # We need all commands loaded.

View File

@@ -1,7 +1,6 @@
module Kamal::Cli module Kamal::Cli
class LockError < StandardError; end class LockError < StandardError; end
class HookError < StandardError; end class HookError < StandardError; end
class BootError < StandardError; end
end end
# SSHKit uses instance eval, so we need a global const for ergonomics # SSHKit uses instance eval, so we need a global const for ergonomics

View File

@@ -1,7 +1,7 @@
class Kamal::Cli::Accessory < Kamal::Cli::Base class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "boot [NAME]", "Boot new accessory service on host (use NAME=all to boot all accessories)" desc "boot [NAME]", "Boot new accessory service on host (use NAME=all to boot all accessories)"
def boot(name, login: true) def boot(name, login: true)
with_lock do mutating do
if name == "all" if name == "all"
KAMAL.accessory_names.each { |accessory_name| boot(accessory_name) } KAMAL.accessory_names.each { |accessory_name| boot(accessory_name) }
else else
@@ -21,7 +21,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "upload [NAME]", "Upload accessory files to host", hide: true desc "upload [NAME]", "Upload accessory files to host", hide: true
def upload(name) def upload(name)
with_lock do mutating do
with_accessory(name) do |accessory, hosts| with_accessory(name) do |accessory, hosts|
on(hosts) do on(hosts) do
accessory.files.each do |(local, remote)| accessory.files.each do |(local, remote)|
@@ -38,7 +38,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "directories [NAME]", "Create accessory directories on host", hide: true desc "directories [NAME]", "Create accessory directories on host", hide: true
def directories(name) def directories(name)
with_lock do mutating do
with_accessory(name) do |accessory, hosts| with_accessory(name) do |accessory, hosts|
on(hosts) do on(hosts) do
accessory.directories.keys.each do |host_path| accessory.directories.keys.each do |host_path|
@@ -51,7 +51,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "reboot [NAME]", "Reboot existing accessory on host (stop container, remove container, start new container; use NAME=all to boot all accessories)" desc "reboot [NAME]", "Reboot existing accessory on host (stop container, remove container, start new container; use NAME=all to boot all accessories)"
def reboot(name) def reboot(name)
with_lock do mutating do
if name == "all" if name == "all"
KAMAL.accessory_names.each { |accessory_name| reboot(accessory_name) } KAMAL.accessory_names.each { |accessory_name| reboot(accessory_name) }
else else
@@ -70,7 +70,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "start [NAME]", "Start existing accessory container on host" desc "start [NAME]", "Start existing accessory container on host"
def start(name) def start(name)
with_lock do mutating do
with_accessory(name) do |accessory, hosts| with_accessory(name) do |accessory, hosts|
on(hosts) do on(hosts) do
execute *KAMAL.auditor.record("Started #{name} accessory"), verbosity: :debug execute *KAMAL.auditor.record("Started #{name} accessory"), verbosity: :debug
@@ -82,7 +82,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "stop [NAME]", "Stop existing accessory container on host" desc "stop [NAME]", "Stop existing accessory container on host"
def stop(name) def stop(name)
with_lock do mutating do
with_accessory(name) do |accessory, hosts| with_accessory(name) do |accessory, hosts|
on(hosts) do on(hosts) do
execute *KAMAL.auditor.record("Stopped #{name} accessory"), verbosity: :debug execute *KAMAL.auditor.record("Stopped #{name} accessory"), verbosity: :debug
@@ -94,7 +94,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "restart [NAME]", "Restart existing accessory container on host" desc "restart [NAME]", "Restart existing accessory container on host"
def restart(name) def restart(name)
with_lock do mutating do
with_accessory(name) do with_accessory(name) do
stop(name) stop(name)
start(name) start(name)
@@ -107,9 +107,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
if name == "all" if name == "all"
KAMAL.accessory_names.each { |accessory_name| details(accessory_name) } KAMAL.accessory_names.each { |accessory_name| details(accessory_name) }
else else
type = "Accessory #{name}"
with_accessory(name) do |accessory, hosts| with_accessory(name) do |accessory, hosts|
on(hosts) { puts_by_host host, capture_with_info(*accessory.info), type: type } on(hosts) { puts capture_with_info(*accessory.info) }
end end
end end
end end
@@ -174,12 +173,17 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "remove [NAME]", "Remove accessory container, image and data directory from host (use NAME=all to remove all accessories)" desc "remove [NAME]", "Remove accessory container, image and data directory from host (use NAME=all to remove all accessories)"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def remove(name) def remove(name)
confirming "This will remove all containers, images and data directories for #{name}. Are you sure?" do mutating do
with_lock do if name == "all"
if name == "all" KAMAL.accessory_names.each { |accessory_name| remove(accessory_name) }
KAMAL.accessory_names.each { |accessory_name| remove_accessory(accessory_name) } else
else confirming "This will remove all containers, images and data directories for #{name}. Are you sure?" do
remove_accessory(name) with_accessory(name) do
stop(name)
remove_container(name)
remove_image(name)
remove_service_directory(name)
end
end end
end end
end end
@@ -187,7 +191,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "remove_container [NAME]", "Remove accessory container from host", hide: true desc "remove_container [NAME]", "Remove accessory container from host", hide: true
def remove_container(name) def remove_container(name)
with_lock do mutating do
with_accessory(name) do |accessory, hosts| with_accessory(name) do |accessory, hosts|
on(hosts) do on(hosts) do
execute *KAMAL.auditor.record("Remove #{name} accessory container"), verbosity: :debug execute *KAMAL.auditor.record("Remove #{name} accessory container"), verbosity: :debug
@@ -199,7 +203,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "remove_image [NAME]", "Remove accessory image from host", hide: true desc "remove_image [NAME]", "Remove accessory image from host", hide: true
def remove_image(name) def remove_image(name)
with_lock do mutating do
with_accessory(name) do |accessory, hosts| with_accessory(name) do |accessory, hosts|
on(hosts) do on(hosts) do
execute *KAMAL.auditor.record("Removed #{name} accessory image"), verbosity: :debug execute *KAMAL.auditor.record("Removed #{name} accessory image"), verbosity: :debug
@@ -211,7 +215,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "remove_service_directory [NAME]", "Remove accessory directory used for uploaded files and data directories from host", hide: true desc "remove_service_directory [NAME]", "Remove accessory directory used for uploaded files and data directories from host", hide: true
def remove_service_directory(name) def remove_service_directory(name)
with_lock do mutating do
with_accessory(name) do |accessory, hosts| with_accessory(name) do |accessory, hosts|
on(hosts) do on(hosts) do
execute *accessory.remove_service_directory execute *accessory.remove_service_directory
@@ -245,13 +249,4 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
accessory.hosts accessory.hosts
end end
end end
def remove_accessory(name)
with_accessory(name) do
stop(name)
remove_container(name)
remove_image(name)
remove_service_directory(name)
end
end
end end

View File

@@ -1,53 +1,47 @@
class Kamal::Cli::App < Kamal::Cli::Base class Kamal::Cli::App < Kamal::Cli::Base
desc "boot", "Boot app on servers (or reboot app if already running)" desc "boot", "Boot app on servers (or reboot app if already running)"
def boot def boot
with_lock do mutating do
say "Get most recent version available as an image...", :magenta unless options[:version] hold_lock_on_error do
using_version(version_or_latest) do |version| say "Get most recent version available as an image...", :magenta unless options[:version]
say "Start container with version #{version} using a #{KAMAL.config.readiness_delay}s readiness delay (or reboot if already running)...", :magenta using_version(version_or_latest) do |version|
say "Start container with version #{version} using a #{KAMAL.config.readiness_delay}s readiness delay (or reboot if already running)...", :magenta
# Assets are prepared in a separate step to ensure they are on all hosts before booting # Assets are prepared in a separate step to ensure they are on all hosts before booting
on(KAMAL.hosts) do on(KAMAL.hosts) do
KAMAL.roles_on(host).each do |role| execute *KAMAL.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug
PrepareAssets.new(host, role, self).run execute *KAMAL.app.tag_current_image_as_latest
KAMAL.roles_on(host).each do |role|
Kamal::Cli::App::PrepareAssets.new(host, role, self).run
end
end end
end
# Primary hosts and roles are returned first, so they can open the barrier
barrier = Barrier.new if KAMAL.roles.many?
on(KAMAL.hosts, **KAMAL.boot_strategy) do |host| on(KAMAL.hosts, **KAMAL.boot_strategy) do |host|
KAMAL.roles_on(host).each do |role| KAMAL.roles_on(host).each do |role|
Boot.new(host, role, self, version, barrier).run Kamal::Cli::App::Boot.new(host, role, version, self).run
end
end end
end end
# Tag once the app booted on all hosts
on(KAMAL.hosts) do |host|
execute *KAMAL.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug
execute *KAMAL.app.tag_latest_image
end
end end
end end
end end
desc "start", "Start existing app container on servers" desc "start", "Start existing app container on servers"
def start def start
with_lock do mutating do
on(KAMAL.hosts) do |host| on(KAMAL.hosts) do |host|
roles = KAMAL.roles_on(host) roles = KAMAL.roles_on(host)
roles.each do |role| roles.each do |role|
app = KAMAL.app(role: role, host: host) app = KAMAL.app(role: role)
execute *KAMAL.auditor.record("Started app version #{KAMAL.config.version}"), verbosity: :debug execute *KAMAL.auditor.record("Started app version #{KAMAL.config.version}"), verbosity: :debug
execute *app.start, raise_on_non_zero_exit: false execute *app.start, raise_on_non_zero_exit: false
if role.running_proxy? if role.running_proxy?
version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip 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 execute *KAMAL.proxy.deploy(app.container_name(version))
raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
execute *KAMAL.proxy.deploy(role.container_prefix, target: endpoint)
end end
end end
end end
@@ -56,21 +50,18 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "stop", "Stop app container on servers" desc "stop", "Stop app container on servers"
def stop def stop
with_lock do mutating do
on(KAMAL.hosts) do |host| on(KAMAL.hosts) do |host|
roles = KAMAL.roles_on(host) roles = KAMAL.roles_on(host)
roles.each do |role| roles.each do |role|
app = KAMAL.app(role: role, host: host) app = KAMAL.app(role: role)
version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
execute *KAMAL.auditor.record("Stopped app", role: role), verbosity: :debug execute *KAMAL.auditor.record("Stopped app", role: role), verbosity: :debug
if role.running_proxy? if role.running_proxy?
endpoint = capture_with_info(*app.container_endpoint(version: version)).strip execute *KAMAL.proxy.remove(app.container_name(version)), raise_on_non_zero_exit: false
if endpoint.present?
execute *KAMAL.proxy.remove(role.container_prefix, target: endpoint), raise_on_non_zero_exit: false
end
end end
execute *app.stop, raise_on_non_zero_exit: false execute *app.stop, raise_on_non_zero_exit: false
@@ -86,23 +77,21 @@ class Kamal::Cli::App < Kamal::Cli::Base
roles = KAMAL.roles_on(host) roles = KAMAL.roles_on(host)
roles.each do |role| roles.each do |role|
puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).info) puts_by_host host, capture_with_info(*KAMAL.app(role: role).info)
end end
end end
end end
desc "exec [CMD]", "Execute a custom command on servers within the app container (use --help to show options)" desc "exec [CMD]", "Execute a custom command on servers (use --help to show options)"
option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)" option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one" option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
option :env, aliases: "-e", type: :hash, desc: "Set environment variables for the command"
def exec(cmd) def exec(cmd)
env = options[:env]
case case
when options[:interactive] && options[:reuse] when options[:interactive] && options[:reuse]
say "Get current version of running container...", :magenta unless options[:version] say "Get current version of running container...", :magenta unless options[:version]
using_version(options[:version] || current_running_version) do |version| using_version(options[:version] || current_running_version) do |version|
say "Launching interactive command with version #{version} via SSH from existing container on #{KAMAL.primary_host}...", :magenta say "Launching interactive command with version #{version} via SSH from existing container on #{KAMAL.primary_host}...", :magenta
run_locally { exec KAMAL.app(role: KAMAL.primary_role, host: KAMAL.primary_host).execute_in_existing_container_over_ssh(cmd, env: env) } run_locally { exec KAMAL.app(role: KAMAL.primary_role).execute_in_existing_container_over_ssh(cmd, host: KAMAL.primary_host) }
end end
when options[:interactive] when options[:interactive]
@@ -110,7 +99,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
using_version(version_or_latest) do |version| using_version(version_or_latest) do |version|
say "Launching interactive command with version #{version} via SSH from new container on #{KAMAL.primary_host}...", :magenta say "Launching interactive command with version #{version} via SSH from new container on #{KAMAL.primary_host}...", :magenta
run_locally do run_locally do
exec KAMAL.app(role: KAMAL.primary_role, host: KAMAL.primary_host).execute_in_new_container_over_ssh(cmd, env: env) exec KAMAL.app(role: KAMAL.primary_role).execute_in_new_container_over_ssh(cmd, host: KAMAL.primary_host)
end end
end end
@@ -124,7 +113,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
roles.each do |role| roles.each do |role|
execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}", role: role), verbosity: :debug execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}", role: role), verbosity: :debug
puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_existing_container(cmd, env: env)) puts_by_host host, capture_with_info(*KAMAL.app(role: role).execute_in_existing_container(cmd))
end end
end end
end end
@@ -138,7 +127,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
roles.each do |role| roles.each do |role|
execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_new_container(cmd, env: env)) puts_by_host host, capture_with_info(*KAMAL.app(role: role).execute_in_new_container(cmd))
end end
end end
end end
@@ -153,21 +142,19 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "stale_containers", "Detect app stale containers" desc "stale_containers", "Detect app stale containers"
option :stop, aliases: "-s", type: :boolean, default: false, desc: "Stop the stale containers found" option :stop, aliases: "-s", type: :boolean, default: false, desc: "Stop the stale containers found"
def stale_containers def stale_containers
stop = options[:stop] mutating do
stop = options[:stop]
cli = self
with_lock_if_stopping do
on(KAMAL.hosts) do |host| on(KAMAL.hosts) do |host|
roles = KAMAL.roles_on(host) roles = KAMAL.roles_on(host)
roles.each do |role| roles.each do |role|
app = KAMAL.app(role: role, host: host) cli.send(:stale_versions, host: host, role: role).each do |version|
versions = capture_with_info(*app.list_versions, raise_on_non_zero_exit: false).split("\n")
versions -= [ capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip ]
versions.each do |version|
if stop if stop
puts_by_host host, "Stopping stale container for role #{role} with version #{version}" puts_by_host host, "Stopping stale container for role #{role} with version #{version}"
execute *app.stop(version: version), raise_on_non_zero_exit: false execute *KAMAL.app(role: role).stop(version: version), raise_on_non_zero_exit: false
else else
puts_by_host host, "Detected stale container for role #{role} with version #{version} (use `kamal app stale_containers --stop` to stop)" puts_by_host host, "Detected stale container for role #{role} with version #{version} (use `kamal app stale_containers --stop` to stop)"
end end
@@ -201,9 +188,8 @@ class Kamal::Cli::App < Kamal::Cli::Base
KAMAL.specific_roles ||= [ "web" ] KAMAL.specific_roles ||= [ "web" ]
role = KAMAL.roles_on(KAMAL.primary_host).first role = KAMAL.roles_on(KAMAL.primary_host).first
app = KAMAL.app(role: role, host: host) info KAMAL.app(role: role).follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep)
info app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep) exec KAMAL.app(role: role).follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep)
exec app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep)
end end
else else
lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
@@ -213,7 +199,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
roles.each do |role| roles.each do |role|
begin begin
puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(since: since, lines: lines, grep: grep)) puts_by_host host, capture_with_info(*KAMAL.app(role: role).logs(since: since, lines: lines, grep: grep))
rescue SSHKit::Command::Failed rescue SSHKit::Command::Failed
puts_by_host host, "Nothing found" puts_by_host host, "Nothing found"
end end
@@ -224,7 +210,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "remove", "Remove app containers and images from servers" desc "remove", "Remove app containers and images from servers"
def remove def remove
with_lock do mutating do
stop stop
remove_containers remove_containers
remove_images remove_images
@@ -233,13 +219,13 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "remove_container [VERSION]", "Remove app container with given version from servers", hide: true desc "remove_container [VERSION]", "Remove app container with given version from servers", hide: true
def remove_container(version) def remove_container(version)
with_lock do mutating do
on(KAMAL.hosts) do |host| on(KAMAL.hosts) do |host|
roles = KAMAL.roles_on(host) roles = KAMAL.roles_on(host)
roles.each do |role| roles.each do |role|
execute *KAMAL.auditor.record("Removed app container with version #{version}", role: role), verbosity: :debug execute *KAMAL.auditor.record("Removed app container with version #{version}", role: role), verbosity: :debug
execute *KAMAL.app(role: role, host: host).remove_container(version: version) execute *KAMAL.app(role: role).remove_container(version: version)
end end
end end
end end
@@ -247,13 +233,13 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "remove_containers", "Remove all app containers from servers", hide: true desc "remove_containers", "Remove all app containers from servers", hide: true
def remove_containers def remove_containers
with_lock do mutating do
on(KAMAL.hosts) do |host| on(KAMAL.hosts) do |host|
roles = KAMAL.roles_on(host) roles = KAMAL.roles_on(host)
roles.each do |role| roles.each do |role|
execute *KAMAL.auditor.record("Removed all app containers", role: role), verbosity: :debug execute *KAMAL.auditor.record("Removed all app containers", role: role), verbosity: :debug
execute *KAMAL.app(role: role, host: host).remove_containers execute *KAMAL.app(role: role).remove_containers
end end
end end
end end
@@ -261,7 +247,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "remove_images", "Remove all app images from servers", hide: true desc "remove_images", "Remove all app images from servers", hide: true
def remove_images def remove_images
with_lock do mutating do
on(KAMAL.hosts) do on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Removed all app images"), verbosity: :debug execute *KAMAL.auditor.record("Removed all app images"), verbosity: :debug
execute *KAMAL.app.remove_images execute *KAMAL.app.remove_images
@@ -273,7 +259,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
def version def version
on(KAMAL.hosts) do |host| on(KAMAL.hosts) do |host|
role = KAMAL.roles_on(host).first role = KAMAL.roles_on(host).first
puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip puts_by_host host, capture_with_info(*KAMAL.app(role: role).current_running_version).strip
end end
end end
@@ -296,20 +282,23 @@ class Kamal::Cli::App < Kamal::Cli::Base
version = nil version = nil
on(host) do on(host) do
role = KAMAL.roles_on(host).first role = KAMAL.roles_on(host).first
version = capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip version = capture_with_info(*KAMAL.app(role: role).current_running_version).strip
end end
version.presence version.presence
end end
def version_or_latest def stale_versions(host:, role:)
options[:version] || KAMAL.config.latest_tag versions = nil
on(host) do
versions = \
capture_with_info(*KAMAL.app(role: role).list_versions, raise_on_non_zero_exit: false)
.split("\n")
.drop(1)
end
versions
end end
def with_lock_if_stopping def version_or_latest
if options[:stop] options[:version] || "latest"
with_lock { yield }
else
yield
end
end end
end end

View File

@@ -1,31 +0,0 @@
class Kamal::Cli::App::Barrier
def initialize
@ivar = Concurrent::IVar.new
end
def close
set(false)
end
def open
set(true)
end
def wait
unless opened?
raise Kamal::Cli::BootError.new("Halted at barrier")
end
end
private
def opened?
@ivar.value
end
def set(value)
@ivar.set(value)
true
rescue Concurrent::MultipleAssignmentError
false
end
end

View File

@@ -1,30 +1,19 @@
class Kamal::Cli::App::Boot class Kamal::Cli::App::Boot
attr_reader :host, :role, :version, :barrier, :sshkit attr_reader :host, :role, :version, :sshkit
delegate :execute, :capture_with_info, :capture_with_pretty_json, :info, :error, to: :sshkit delegate :execute, :capture_with_info, :info, to: :sshkit
delegate :assets?, :running_proxy?, to: :role delegate :assets?, :running_proxy?, to: :role
def initialize(host, role, sshkit, version, barrier) def initialize(host, role, version, sshkit)
@host = host @host = host
@role = role @role = role
@version = version @version = version
@barrier = barrier
@sshkit = sshkit @sshkit = sshkit
end end
def run def run
old_version = old_version_renamed_if_clashing old_version = old_version_renamed_if_clashing
wait_at_barrier if queuer? start_new_version
begin
start_new_version
rescue => e
close_barrier if gatekeeper?
stop_new_version
raise
end
release_barrier if gatekeeper?
if old_version if old_version
stop_old_version(old_version) stop_old_version(old_version)
@@ -32,6 +21,18 @@ class Kamal::Cli::App::Boot
end end
private private
def app
@app ||= KAMAL.app(role: role)
end
def auditor
@auditor = KAMAL.auditor(role: role)
end
def audit(message)
execute *auditor.record(message), verbosity: :debug
end
def old_version_renamed_if_clashing def old_version_renamed_if_clashing
if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present? if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present?
renamed_version = "#{version}_replaced_#{SecureRandom.hex(8)}" renamed_version = "#{version}_replaced_#{SecureRandom.hex(8)}"
@@ -45,67 +46,14 @@ class Kamal::Cli::App::Boot
def start_new_version def start_new_version
audit "Booted app version #{version}" audit "Booted app version #{version}"
hostname = "#{host.to_s[0...51].gsub(/\.+$/, '')}-#{SecureRandom.hex(6)}" execute *app.run(hostname: "#{host}-#{SecureRandom.hex(6)}")
execute *app.run(hostname: hostname)
if running_proxy? if running_proxy?
endpoint = capture_with_info(*app.container_endpoint(version: version)).strip execute *KAMAL.proxy.deploy("#{app.container_name(version)}:#{role.port}")
raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
execute *KAMAL.proxy.deploy(role.container_prefix, target: endpoint)
end end
end end
def stop_new_version
execute *app.stop(version: version), raise_on_non_zero_exit: false
end
def stop_old_version(version) def stop_old_version(version)
execute *app.stop(version: version), raise_on_non_zero_exit: false execute *app.stop(version: version), raise_on_non_zero_exit: false
execute *app.clean_up_assets if assets? execute *app.clean_up_assets if assets?
end end
def release_barrier
if barrier.open
info "First #{KAMAL.primary_role} container is healthy on #{host}, booting other roles"
end
end
def wait_at_barrier
info "Waiting for the first healthy #{KAMAL.primary_role} container before booting #{role} on #{host}..."
barrier.wait
info "First #{KAMAL.primary_role} container is healthy, booting #{role} on #{host}..."
rescue Kamal::Cli::BootError
info "First #{KAMAL.primary_role} container is unhealthy, not booting #{role} on #{host}"
raise
end
def close_barrier
if barrier.close
info "First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting other roles"
error capture_with_info(*app.logs(version: version))
end
end
def barrier_role?
role == KAMAL.primary_role
end
def app
@app ||= KAMAL.app(role: role, host: host)
end
def auditor
@auditor = KAMAL.auditor(role: role)
end
def audit(message)
execute *auditor.record(message), verbosity: :debug
end
def gatekeeper?
barrier && barrier_role?
end
def queuer?
barrier && !barrier_role?
end
end end

View File

@@ -19,6 +19,6 @@ class Kamal::Cli::App::PrepareAssets
private private
def app def app
@app ||= KAMAL.app(role: role, host: host) @app ||= KAMAL.app(role: role)
end end
end end

View File

@@ -79,27 +79,28 @@ module Kamal::Cli
puts " Finished all in #{sprintf("%.1f seconds", runtime)}" puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
end end
def with_lock def mutating
if KAMAL.holding_lock? return yield if KAMAL.holding_lock?
run_hook "pre-connect"
ensure_run_and_locks_directory
acquire_lock
begin
yield yield
else rescue
ensure_run_and_locks_directory if KAMAL.hold_lock_on_error?
error " \e[31mDeploy lock was not released\e[0m"
acquire_lock else
release_lock
begin
yield
rescue
begin
release_lock
rescue => e
say "Error releasing the deploy lock: #{e.message}", :red
end
raise
end end
release_lock raise
end end
release_lock
end end
def confirming(question) def confirming(question)
@@ -140,28 +141,29 @@ module Kamal::Cli
end end
end end
def hold_lock_on_error
if KAMAL.hold_lock_on_error?
yield
else
KAMAL.hold_lock_on_error = true
yield
KAMAL.hold_lock_on_error = false
end
end
def run_hook(hook, **extra_details) def run_hook(hook, **extra_details)
if !options[:skip_hooks] && KAMAL.hook.hook_exists?(hook) if !options[:skip_hooks] && KAMAL.hook.hook_exists?(hook)
details = { hosts: KAMAL.hosts.join(","), command: command, subcommand: subcommand } details = { hosts: KAMAL.hosts.join(","), command: command, subcommand: subcommand }
say "Running the #{hook} hook...", :magenta say "Running the #{hook} hook...", :magenta
run_locally do run_locally do
execute *KAMAL.hook.run(hook, **details, **extra_details) KAMAL.with_verbosity(:debug) { execute *KAMAL.hook.run(hook, **details, **extra_details) }
rescue SSHKit::Command::Failed => e rescue SSHKit::Command::Failed
raise HookError.new("Hook `#{hook}` failed:\n#{e.message}") raise HookError.new("Hook `#{hook}` failed")
end end
end end
end end
def on(*args, &block)
if !KAMAL.connected?
run_hook "pre-connect"
KAMAL.connected = true
end
super
end
def command def command
@kamal_command ||= begin @kamal_command ||= begin
invocation_class, invocation_commands = *first_invocation invocation_class, invocation_commands = *first_invocation

View File

@@ -5,50 +5,39 @@ class Kamal::Cli::Build < Kamal::Cli::Base
desc "deliver", "Build app and push app image to registry then pull image on servers" desc "deliver", "Build app and push app image to registry then pull image on servers"
def deliver def deliver
push mutating do
pull push
pull
end
end end
desc "push", "Build and push app image to registry" desc "push", "Build and push app image to registry"
def push def push
cli = self mutating do
cli = self
verify_local_dependencies verify_local_dependencies
run_hook "pre-build" run_hook "pre-build"
uncommitted_changes = Kamal::Git.uncommitted_changes if (uncommitted_changes = Kamal::Git.uncommitted_changes).present?
say "The following paths have uncommitted changes:\n #{uncommitted_changes}", :yellow
if KAMAL.config.builder.git_clone?
if uncommitted_changes.present?
say "Building from a local git clone, so ignoring these uncommitted changes:\n #{uncommitted_changes}", :yellow
end end
run_locally do run_locally do
Clone.new(self).prepare begin
end KAMAL.with_verbosity(:debug) do
elsif uncommitted_changes.present? execute *KAMAL.builder.push
say "Building with uncommitted changes:\n #{uncommitted_changes}", :yellow end
end rescue SSHKit::Command::Failed => e
if e.message =~ /(no builder)|(no such file or directory)/
# Get the command here to ensure the Dir.chdir doesn't interfere with it error "Missing compatible builder, so creating a new one first"
push = KAMAL.builder.push
if cli.create
run_locally do KAMAL.with_verbosity(:debug) { execute *KAMAL.builder.push }
begin end
KAMAL.with_verbosity(:debug) do else
Dir.chdir(KAMAL.config.builder.build_directory) { execute *push } raise
end
rescue SSHKit::Command::Failed => e
if e.message =~ /(no builder)|(no such file or directory)/
warn "Missing compatible builder, so creating a new one first"
if cli.create
KAMAL.with_verbosity(:debug) do
Dir.chdir(KAMAL.config.builder.build_directory) { execute *push }
end
end end
else
raise
end end
end end
end end
@@ -56,30 +45,34 @@ class Kamal::Cli::Build < Kamal::Cli::Base
desc "pull", "Pull app image from registry onto servers" desc "pull", "Pull app image from registry onto servers"
def pull def pull
on(KAMAL.hosts) do mutating do
execute *KAMAL.auditor.record("Pulled image with version #{KAMAL.config.version}"), verbosity: :debug on(KAMAL.hosts) do
execute *KAMAL.builder.clean, raise_on_non_zero_exit: false execute *KAMAL.auditor.record("Pulled image with version #{KAMAL.config.version}"), verbosity: :debug
execute *KAMAL.builder.pull execute *KAMAL.builder.clean, raise_on_non_zero_exit: false
execute *KAMAL.builder.validate_image execute *KAMAL.builder.pull
execute *KAMAL.builder.validate_image
end
end end
end end
desc "create", "Create a build setup" desc "create", "Create a build setup"
def create def create
if (remote_host = KAMAL.config.builder.remote_host) mutating do
connect_to_remote_host(remote_host) if (remote_host = KAMAL.config.builder.remote_host)
end connect_to_remote_host(remote_host)
end
run_locally do run_locally do
begin begin
debug "Using builder: #{KAMAL.builder.name}" debug "Using builder: #{KAMAL.builder.name}"
execute *KAMAL.builder.create execute *KAMAL.builder.create
rescue SSHKit::Command::Failed => e rescue SSHKit::Command::Failed => e
if e.message =~ /stderr=(.*)/ if e.message =~ /stderr=(.*)/
error "Couldn't create remote builder: #{$1}" error "Couldn't create remote builder: #{$1}"
false false
else else
raise raise
end
end end
end end
end end
@@ -87,9 +80,11 @@ class Kamal::Cli::Build < Kamal::Cli::Base
desc "remove", "Remove build setup" desc "remove", "Remove build setup"
def remove def remove
run_locally do mutating do
debug "Using builder: #{KAMAL.builder.name}" run_locally do
execute *KAMAL.builder.remove debug "Using builder: #{KAMAL.builder.name}"
execute *KAMAL.builder.remove
end
end end
end end
@@ -119,11 +114,8 @@ class Kamal::Cli::Build < Kamal::Cli::Base
def connect_to_remote_host(remote_host) def connect_to_remote_host(remote_host)
remote_uri = URI.parse(remote_host) remote_uri = URI.parse(remote_host)
if remote_uri.scheme == "ssh" if remote_uri.scheme == "ssh"
host = SSHKit::Host.new( options = { user: remote_uri.user, port: remote_uri.port }.compact
hostname: remote_uri.host, on(remote_uri.host, options) do
ssh_options: { user: remote_uri.user, port: remote_uri.port }.compact
)
on(host, options) do
execute "true" execute "true"
end end
end end

View File

@@ -1,61 +0,0 @@
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

@@ -3,13 +3,13 @@ require "tempfile"
class Kamal::Cli::Env < Kamal::Cli::Base class Kamal::Cli::Env < Kamal::Cli::Base
desc "push", "Push the env file to the remote hosts" desc "push", "Push the env file to the remote hosts"
def push def push
with_lock do mutating do
on(KAMAL.hosts) do on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Pushed env files"), verbosity: :debug execute *KAMAL.auditor.record("Pushed env files"), verbosity: :debug
KAMAL.roles_on(host).each do |role| KAMAL.roles_on(host).each do |role|
execute *KAMAL.app(role: role, host: host).make_env_directory execute *KAMAL.app(role: role).make_env_directory
upload! role.env(host).secrets_io, role.env(host).secrets_file, mode: 400 upload! role.env.secrets_io, role.env.secrets_file, mode: 400
end end
end end
@@ -25,12 +25,12 @@ class Kamal::Cli::Env < Kamal::Cli::Base
desc "delete", "Delete the env file from the remote hosts" desc "delete", "Delete the env file from the remote hosts"
def delete def delete
with_lock do mutating do
on(KAMAL.hosts) do on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Deleted env files"), verbosity: :debug execute *KAMAL.auditor.record("Deleted env files"), verbosity: :debug
KAMAL.roles_on(host).each do |role| KAMAL.roles_on(host).each do |role|
execute *KAMAL.app(role: role, host: host).remove_env_file execute *KAMAL.app(role: role).remove_env_file
end end
end end

View File

@@ -0,0 +1,21 @@
class Kamal::Cli::Healthcheck < Kamal::Cli::Base
default_command :perform
desc "perform", "Health check current app version"
def perform
raise "The primary host is not configured to run a proxy" unless KAMAL.config.role(KAMAL.config.primary_role).running_proxy?
on(KAMAL.primary_host) do
begin
execute *KAMAL.healthcheck.run
Poller.wait_for_healthy { capture_with_info(*KAMAL.healthcheck.status) }
rescue Poller::HealthcheckError => e
error capture_with_info(*KAMAL.healthcheck.logs)
error capture_with_pretty_json(*KAMAL.healthcheck.container_health_log)
raise
ensure
execute *KAMAL.healthcheck.stop, raise_on_non_zero_exit: false
execute *KAMAL.healthcheck.remove, raise_on_non_zero_exit: false
end
end
end
end

View File

@@ -0,0 +1,64 @@
module Kamal::Cli::Healthcheck::Poller
extend self
TRAEFIK_UPDATE_DELAY = 5
class HealthcheckError < StandardError; end
def wait_for_healthy(pause_after_ready: false, &block)
attempt = 1
max_attempts = KAMAL.config.healthcheck["max_attempts"]
begin
case status = block.call
when "healthy"
sleep TRAEFIK_UPDATE_DELAY if pause_after_ready
when "running" # No health check configured
sleep KAMAL.config.readiness_delay if pause_after_ready
else
raise HealthcheckError, "container not ready (#{status})"
end
rescue HealthcheckError => e
if attempt <= max_attempts
info "#{e.message}, retrying in #{attempt}s (attempt #{attempt}/#{max_attempts})..."
sleep attempt
attempt += 1
retry
else
raise
end
end
info "Container is healthy!"
end
def wait_for_unhealthy(pause_after_ready: false, &block)
attempt = 1
max_attempts = KAMAL.config.healthcheck["max_attempts"]
begin
case status = block.call
when "unhealthy"
sleep TRAEFIK_UPDATE_DELAY if pause_after_ready
else
raise HealthcheckError, "container not unhealthy (#{status})"
end
rescue HealthcheckError => e
if attempt <= max_attempts
info "#{e.message}, retrying in #{attempt}s (attempt #{attempt}/#{max_attempts})..."
sleep attempt
attempt += 1
retry
else
raise
end
end
info "Container is unhealthy!"
end
private
def info(message)
SSHKit.config.output.info(message)
end
end

View File

@@ -3,14 +3,14 @@ class Kamal::Cli::Main < Kamal::Cli::Base
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
def setup def setup
print_runtime do print_runtime do
with_lock do mutating do
invoke_options = deploy_options invoke_options = deploy_options
say "Ensure Docker is installed...", :magenta say "Ensure Docker is installed...", :magenta
invoke "kamal:cli:server:bootstrap", [], invoke_options invoke "kamal:cli:server:bootstrap", [], invoke_options
say "Evaluate and push env files...", :magenta say "Push env files...", :magenta
invoke "kamal:cli:main:envify", [], invoke_options invoke "kamal:cli:env:push", [], invoke_options
invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options
deploy deploy
@@ -22,25 +22,30 @@ class Kamal::Cli::Main < Kamal::Cli::Base
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
def deploy def deploy
runtime = print_runtime do runtime = print_runtime do
invoke_options = deploy_options mutating do
invoke_options = deploy_options
say "Log into image registry...", :magenta say "Log into image registry...", :magenta
invoke "kamal:cli:registry:login", [], invoke_options invoke "kamal:cli:registry:login", [], invoke_options
if options[:skip_push] if options[:skip_push]
say "Pull app image...", :magenta say "Pull app image...", :magenta
invoke "kamal:cli:build:pull", [], invoke_options invoke "kamal:cli:build:pull", [], invoke_options
else else
say "Build and push app image...", :magenta say "Build and push app image...", :magenta
invoke "kamal:cli:build:deliver", [], invoke_options invoke "kamal:cli:build:deliver", [], invoke_options
end end
with_lock do
run_hook "pre-deploy" run_hook "pre-deploy"
say "Ensure proxy is running...", :magenta say "Ensure proxy is running...", :magenta
invoke "kamal:cli:proxy:boot", [], invoke_options invoke "kamal:cli:proxy:boot", [], invoke_options
if KAMAL.config.role(KAMAL.config.primary_role).running_proxy?
say "Ensure app can pass healthcheck...", :magenta
invoke "kamal:cli:healthcheck:perform", [], invoke_options
end
say "Detect stale containers...", :magenta say "Detect stale containers...", :magenta
invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true) invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)
@@ -58,19 +63,22 @@ class Kamal::Cli::Main < Kamal::Cli::Base
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
def redeploy def redeploy
runtime = print_runtime do runtime = print_runtime do
invoke_options = deploy_options mutating do
invoke_options = deploy_options
if options[:skip_push] if options[:skip_push]
say "Pull app image...", :magenta say "Pull app image...", :magenta
invoke "kamal:cli:build:pull", [], invoke_options invoke "kamal:cli:build:pull", [], invoke_options
else else
say "Build and push app image...", :magenta say "Build and push app image...", :magenta
invoke "kamal:cli:build:deliver", [], invoke_options invoke "kamal:cli:build:deliver", [], invoke_options
end end
with_lock do
run_hook "pre-deploy" run_hook "pre-deploy"
say "Ensure app can pass healthcheck...", :magenta
invoke "kamal:cli:healthcheck:perform", [], invoke_options
say "Detect stale containers...", :magenta say "Detect stale containers...", :magenta
invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true) invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)
@@ -85,7 +93,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
def rollback(version) def rollback(version)
rolled_back = false rolled_back = false
runtime = print_runtime do runtime = print_runtime do
with_lock do mutating do
invoke_options = deploy_options invoke_options = deploy_options
KAMAL.config.version = version KAMAL.config.version = version
@@ -177,23 +185,19 @@ class Kamal::Cli::Main < Kamal::Cli::Base
env_path = ".env" env_path = ".env"
end end
if Pathname.new(File.expand_path(env_template_path)).exist? File.write(env_path, ERB.new(File.read(env_template_path), trim_mode: "-").result, perm: 0600)
File.write(env_path, ERB.new(File.read(env_template_path), trim_mode: "-").result, perm: 0600)
unless options[:skip_push] unless options[:skip_push]
reload_envs reload_envs
invoke "kamal:cli:env:push", options invoke "kamal:cli:env:push", options
end
else
puts "Skipping envify (no #{env_template_path} exist)"
end end
end end
desc "remove", "Remove proxy, app, accessories, and registry session from servers" desc "remove", "Remove proxy, app, accessories, and registry session from servers"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def remove def remove
confirming "This will remove all containers and images. Are you sure?" do mutating do
with_lock do confirming "This will remove all containers and images. Are you sure?" do
invoke "kamal:cli:proxy:remove", [], options.without(:confirmed) invoke "kamal:cli:proxy:remove", [], options.without(:confirmed)
invoke "kamal:cli:app:remove", [], options.without(:confirmed) invoke "kamal:cli:app:remove", [], options.without(:confirmed)
invoke "kamal:cli:accessory:remove", [ "all" ], options invoke "kamal:cli:accessory:remove", [ "all" ], options
@@ -219,6 +223,9 @@ class Kamal::Cli::Main < Kamal::Cli::Base
desc "env", "Manage environment files" desc "env", "Manage environment files"
subcommand "env", Kamal::Cli::Env subcommand "env", Kamal::Cli::Env
desc "healthcheck", "Healthcheck application"
subcommand "healthcheck", Kamal::Cli::Healthcheck
desc "lock", "Manage the deploy lock" desc "lock", "Manage the deploy lock"
subcommand "lock", Kamal::Cli::Lock subcommand "lock", Kamal::Cli::Lock
@@ -239,11 +246,11 @@ class Kamal::Cli::Main < Kamal::Cli::Base
begin begin
on(KAMAL.hosts) do on(KAMAL.hosts) do
KAMAL.roles_on(host).each do |role| KAMAL.roles_on(host).each do |role|
container_id = capture_with_info(*KAMAL.app(role: role, host: host).container_id_for_version(version)) container_id = capture_with_info(*KAMAL.app(role: role).container_id_for_version(version))
raise "Container not found" unless container_id.present? raise "Container not found" unless container_id.present?
end end
end end
rescue SSHKit::Runner::ExecuteError, SSHKit::Runner::MultipleExecuteError => e rescue SSHKit::Runner::ExecuteError => e
if e.message =~ /Container not found/ if e.message =~ /Container not found/
say "Error looking for container version #{version}: #{e.message}" say "Error looking for container version #{version}: #{e.message}"
return false return false

View File

@@ -1,7 +1,7 @@
class Kamal::Cli::Proxy < Kamal::Cli::Base class Kamal::Cli::Proxy < Kamal::Cli::Base
desc "boot", "Boot proxy on servers" desc "boot", "Boot proxy on servers"
def boot def boot
with_lock do mutating do
on(KAMAL.proxy_hosts) do on(KAMAL.proxy_hosts) do
execute *KAMAL.registry.login execute *KAMAL.registry.login
execute *KAMAL.proxy.start_or_run execute *KAMAL.proxy.start_or_run
@@ -14,7 +14,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def reboot def reboot
confirming "This will cause a brief outage on each host. Are you sure?" do confirming "This will cause a brief outage on each host. Are you sure?" do
with_lock do mutating do
host_groups = options[:rolling] ? KAMAL.proxy_hosts : [ KAMAL.proxy_hosts ] host_groups = options[:rolling] ? KAMAL.proxy_hosts : [ KAMAL.proxy_hosts ]
host_groups.each do |hosts| host_groups.each do |hosts|
host_list = Array(hosts).join(",") host_list = Array(hosts).join(",")
@@ -34,7 +34,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
desc "start", "Start existing proxy container on servers" desc "start", "Start existing proxy container on servers"
def start def start
with_lock do mutating do
on(KAMAL.proxy_hosts) do on(KAMAL.proxy_hosts) do
execute *KAMAL.auditor.record("Started proxy"), verbosity: :debug execute *KAMAL.auditor.record("Started proxy"), verbosity: :debug
execute *KAMAL.proxy.start execute *KAMAL.proxy.start
@@ -44,7 +44,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
desc "stop", "Stop existing proxy container on servers" desc "stop", "Stop existing proxy container on servers"
def stop def stop
with_lock do mutating do
on(KAMAL.proxy_hosts) do on(KAMAL.proxy_hosts) do
execute *KAMAL.auditor.record("Stopped proxy"), verbosity: :debug execute *KAMAL.auditor.record("Stopped proxy"), verbosity: :debug
execute *KAMAL.proxy.stop, raise_on_non_zero_exit: false execute *KAMAL.proxy.stop, raise_on_non_zero_exit: false
@@ -54,56 +54,12 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
desc "restart", "Restart existing proxy container on servers" desc "restart", "Restart existing proxy container on servers"
def restart def restart
with_lock do mutating do
stop stop
start start
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" }
@@ -135,7 +91,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
desc "remove", "Remove proxy container and image from servers" desc "remove", "Remove proxy container and image from servers"
def remove def remove
with_lock do mutating do
stop stop
remove_container remove_container
remove_image remove_image
@@ -144,7 +100,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
desc "remove_container", "Remove proxy container from servers", hide: true desc "remove_container", "Remove proxy container from servers", hide: true
def remove_container def remove_container
with_lock do mutating do
on(KAMAL.proxy_hosts) do on(KAMAL.proxy_hosts) do
execute *KAMAL.auditor.record("Removed proxy container"), verbosity: :debug execute *KAMAL.auditor.record("Removed proxy container"), verbosity: :debug
execute *KAMAL.proxy.remove_container execute *KAMAL.proxy.remove_container
@@ -154,7 +110,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
desc "remove_image", "Remove proxy image from servers", hide: true desc "remove_image", "Remove proxy image from servers", hide: true
def remove_image def remove_image
with_lock do mutating do
on(KAMAL.proxy_hosts) do on(KAMAL.proxy_hosts) do
execute *KAMAL.auditor.record("Removed proxy image"), verbosity: :debug execute *KAMAL.auditor.record("Removed proxy image"), verbosity: :debug
execute *KAMAL.proxy.remove_image execute *KAMAL.proxy.remove_image

View File

@@ -1,7 +1,7 @@
class Kamal::Cli::Prune < Kamal::Cli::Base class Kamal::Cli::Prune < Kamal::Cli::Base
desc "all", "Prune unused images and stopped containers" desc "all", "Prune unused images and stopped containers"
def all def all
with_lock do mutating do
containers containers
images images
end end
@@ -9,7 +9,7 @@ class Kamal::Cli::Prune < Kamal::Cli::Base
desc "images", "Prune unused images" desc "images", "Prune unused images"
def images def images
with_lock do mutating do
on(KAMAL.hosts) do on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Pruned images"), verbosity: :debug execute *KAMAL.auditor.record("Pruned images"), verbosity: :debug
execute *KAMAL.prune.dangling_images execute *KAMAL.prune.dangling_images
@@ -24,10 +24,11 @@ class Kamal::Cli::Prune < Kamal::Cli::Base
retain = options.fetch(:retain, KAMAL.config.retain_containers) retain = options.fetch(:retain, KAMAL.config.retain_containers)
raise "retain must be at least 1" if retain < 1 raise "retain must be at least 1" if retain < 1
with_lock do mutating do
on(KAMAL.hosts) do on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Pruned containers"), verbosity: :debug execute *KAMAL.auditor.record("Pruned containers"), verbosity: :debug
execute *KAMAL.prune.app_containers(retain: retain) execute *KAMAL.prune.app_containers(retain: retain)
execute *KAMAL.prune.healthcheck_containers
end end
end end
end end

View File

@@ -1,49 +1,33 @@
class Kamal::Cli::Server < Kamal::Cli::Base class Kamal::Cli::Server < Kamal::Cli::Base
desc "exec", "Run a custom command on the server (use --help to show options)"
option :interactive, type: :boolean, aliases: "-i", default: false, desc: "Run the command interactively (use for console/bash)"
def exec(cmd)
hosts = KAMAL.hosts | KAMAL.accessory_hosts
case
when options[:interactive]
host = KAMAL.primary_host
say "Running '#{cmd}' on #{host} interactively...", :magenta
run_locally { exec KAMAL.server.run_over_ssh(cmd, host: host) }
else
say "Running '#{cmd}' on #{hosts.join(', ')}...", :magenta
on(hosts) do |host|
execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{host}"), verbosity: :debug
puts_by_host host, capture_with_info(cmd)
end
end
end
desc "bootstrap", "Set up Docker to run Kamal apps" desc "bootstrap", "Set up Docker to run Kamal apps"
def bootstrap def bootstrap
with_lock do missing = []
missing = []
on(KAMAL.hosts | KAMAL.accessory_hosts) do |host| on(KAMAL.hosts | KAMAL.accessory_hosts) do |host|
unless execute(*KAMAL.docker.installed?, raise_on_non_zero_exit: false) unless execute(*KAMAL.docker.installed?, raise_on_non_zero_exit: false)
if execute(*KAMAL.docker.superuser?, raise_on_non_zero_exit: false) if execute(*KAMAL.docker.superuser?, raise_on_non_zero_exit: false)
info "Missing Docker on #{host}. Installing…" info "Missing Docker on #{host}. Installing…"
execute *KAMAL.docker.install execute *KAMAL.docker.install
else else
missing << host missing << host
end
end end
execute(*KAMAL.server.ensure_run_directory)
end end
if missing.any? execute(*KAMAL.server.ensure_run_directory)
raise "Docker is not installed on #{missing.join(", ")} and can't be automatically installed without having root access and either `wget` or `curl`. Install Docker manually: https://docs.docker.com/engine/install/"
end
run_hook "docker-setup" begin
execute(*KAMAL.docker.create_kamal_network)
rescue SSHKit::Command::Failed => e
if e.message !~ /network with name kamal already exists/
raise
end
end
end end
if missing.any?
raise "Docker is not installed on #{missing.join(", ")} and can't be automatically installed without having root access and either `wget` or `curl`. Install Docker manually: https://docs.docker.com/engine/install/"
end
run_hook "docker-setup"
end end
end end

View File

@@ -63,6 +63,11 @@ registry:
# directories: # directories:
# - data:/data # - data:/data
# Configure a custom healthcheck (default is /up on port 3000)
# healthcheck:
# path: /healthz
# port: 4000
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid # Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old # hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path. # version inside the asset_path.

View File

View File

@@ -2,14 +2,12 @@ require "active_support/core_ext/enumerable"
require "active_support/core_ext/module/delegation" require "active_support/core_ext/module/delegation"
class Kamal::Commander class Kamal::Commander
attr_accessor :verbosity, :holding_lock, :connected attr_accessor :verbosity, :holding_lock, :hold_lock_on_error
delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :proxy_hosts, :accessory_hosts, to: :specifics
def initialize def initialize
self.verbosity = :info self.verbosity = :info
self.holding_lock = false self.holding_lock = false
self.connected = false self.hold_lock_on_error = false
@specifics = nil
end end
def config def config
@@ -26,12 +24,10 @@ class Kamal::Commander
attr_reader :specific_roles, :specific_hosts attr_reader :specific_roles, :specific_hosts
def specific_primary! def specific_primary!
@specifics = nil
self.specific_hosts = [ config.primary_host ] self.specific_hosts = [ config.primary_host ]
end end
def specific_roles=(role_names) def specific_roles=(role_names)
@specifics = nil
if role_names.present? if role_names.present?
@specific_roles = Kamal::Utils.filter_specific_items(role_names, config.roles) @specific_roles = Kamal::Utils.filter_specific_items(role_names, config.roles)
@@ -44,7 +40,6 @@ class Kamal::Commander
end end
def specific_hosts=(hosts) def specific_hosts=(hosts)
@specifics = nil
if hosts.present? if hosts.present?
@specific_hosts = Kamal::Utils.filter_specific_items(hosts, config.all_hosts) @specific_hosts = Kamal::Utils.filter_specific_items(hosts, config.all_hosts)
@@ -56,6 +51,39 @@ class Kamal::Commander
end end
end end
def primary_host
# Given a list of specific roles, make an effort to match up with the primary_role
specific_hosts&.first || specific_roles&.detect { |role| role == config.primary_role }&.primary_host || specific_roles&.first&.primary_host || config.primary_host
end
def primary_role
roles_on(primary_host).first
end
def roles
(specific_roles || config.roles).select do |role|
((specific_hosts || config.all_hosts) & role.hosts).any?
end
end
def hosts
(specific_hosts || config.all_hosts).select do |host|
(specific_roles || config.roles).flat_map(&:hosts).include?(host)
end
end
def roles_on(host)
roles.select { |role| role.hosts.include?(host.to_s) }
end
def proxy_hosts
specific_hosts || config.proxy_hosts
end
def accessory_hosts
specific_hosts || config.accessories.flat_map(&:hosts)
end
def accessory_names def accessory_names
config.accessories&.collect(&:name) || [] config.accessories&.collect(&:name) || []
end end
@@ -65,8 +93,8 @@ class Kamal::Commander
end end
def app(role: nil, host: nil) def app(role: nil)
Kamal::Commands::App.new(config, role: role, host: host) Kamal::Commands::App.new(config, role: role)
end end
def accessory(name) def accessory(name)
@@ -85,6 +113,10 @@ class Kamal::Commander
@docker ||= Kamal::Commands::Docker.new(config) @docker ||= Kamal::Commands::Docker.new(config)
end end
def healthcheck
@healthcheck ||= Kamal::Commands::Healthcheck.new(config)
end
def hook def hook
@hook ||= Kamal::Commands::Hook.new(config) @hook ||= Kamal::Commands::Hook.new(config)
end end
@@ -134,8 +166,8 @@ class Kamal::Commander
self.holding_lock self.holding_lock
end end
def connected? def hold_lock_on_error?
self.connected self.hold_lock_on_error
end end
private private
@@ -149,8 +181,4 @@ class Kamal::Commander
SSHKit.config.command_map[:docker] = "docker" # No need to use /usr/bin/env, just clogs up the logs SSHKit.config.command_map[:docker] = "docker" # No need to use /usr/bin/env, just clogs up the logs
SSHKit.config.output_verbosity = verbosity SSHKit.config.output_verbosity = verbosity
end end
def specifics
@specifics ||= Kamal::Commander::Specifics.new(config, specific_hosts, specific_roles)
end
end end

View File

@@ -1,49 +0,0 @@
class Kamal::Commander::Specifics
attr_reader :primary_host, :primary_role, :hosts, :roles
delegate :stable_sort!, to: Kamal::Utils
def initialize(config, specific_hosts, specific_roles)
@config, @specific_hosts, @specific_roles = config, specific_hosts, specific_roles
@roles, @hosts = specified_roles, specified_hosts
@primary_host = specific_hosts&.first || primary_specific_role&.primary_host || config.primary_host
@primary_role = primary_or_first_role(roles_on(primary_host))
stable_sort!(roles) { |role| role == primary_role ? 0 : 1 }
stable_sort!(hosts) { |host| roles_on(host).any? { |role| role == primary_role } ? 0 : 1 }
end
def roles_on(host)
roles.select { |role| role.hosts.include?(host.to_s) }
end
def proxy_hosts
config.proxy_hosts & specified_hosts
end
def accessory_hosts
specific_hosts || config.accessories.flat_map(&:hosts)
end
private
attr_reader :config, :specific_hosts, :specific_roles
def primary_specific_role
primary_or_first_role(specific_roles) if specific_roles.present?
end
def primary_or_first_role(roles)
roles.detect { |role| role == config.primary_role } || roles.first
end
def specified_roles
(specific_roles || config.roles) \
.select { |role| ((specific_hosts || config.all_hosts) & role.hosts).any? }
end
def specified_hosts
(specific_hosts || config.all_hosts) \
.select { |host| (specific_roles || config.roles).flat_map(&:hosts).include?(host) }
end
end

View File

@@ -3,12 +3,11 @@ class Kamal::Commands::App < Kamal::Commands::Base
ACTIVE_DOCKER_STATUSES = [ :running, :restarting ] ACTIVE_DOCKER_STATUSES = [ :running, :restarting ]
attr_reader :role, :host attr_reader :role, :role
def initialize(config, role: nil, host: nil) def initialize(config, role: nil)
super(config) super(config)
@role = role @role = role
@host = host
end end
def run(hostname: nil) def run(hostname: nil)
@@ -16,10 +15,12 @@ class Kamal::Commands::App < Kamal::Commands::Base
"--detach", "--detach",
"--restart unless-stopped", "--restart unless-stopped",
"--name", container_name, "--name", container_name,
"--network kamal",
*([ "--hostname", hostname ] if hostname), *([ "--hostname", hostname ] if hostname),
"-e", "KAMAL_CONTAINER_NAME=\"#{container_name}\"", "-e", "KAMAL_CONTAINER_NAME=\"#{container_name}\"",
"-e", "KAMAL_VERSION=\"#{config.version}\"", "-e", "KAMAL_VERSION=\"#{config.version}\"",
*role.env_args(host), *role.env_args,
*role.health_check_args,
*role.logging_args, *role.logging_args,
*config.volume_args, *config.volume_args,
*role.asset_volume_args, *role.asset_volume_args,
@@ -49,7 +50,7 @@ class Kamal::Commands::App < Kamal::Commands::Base
def current_running_container_id def current_running_container_id
current_running_container(format: "--quiet") docker :ps, "--quiet", *filter_args(statuses: ACTIVE_DOCKER_STATUSES), "--latest"
end end
def container_id_for_version(version, only_running: false) def container_id_for_version(version, only_running: false)
@@ -61,59 +62,30 @@ class Kamal::Commands::App < Kamal::Commands::Base
end end
def current_running_version def current_running_version
pipe \ list_versions("--latest", statuses: ACTIVE_DOCKER_STATUSES)
current_running_container(format: "--format '{{.Names}}'"),
extract_version_from_name
end end
def list_versions(*docker_args, statuses: nil) def list_versions(*docker_args, statuses: nil)
pipe \ pipe \
docker(:ps, *filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'), docker(:ps, *filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'),
extract_version_from_name %(while read line; do echo ${line##{role.container_prefix}-}; done) # Extract SHA from "service-role-dest-SHA"
end end
def make_env_directory def make_env_directory
make_directory role.env(host).secrets_directory make_directory role.env.secrets_directory
end end
def remove_env_file def remove_env_file
[ :rm, "-f", role.env(host).secrets_file ] [ :rm, "-f", role.env.secrets_file ]
end end
private private
def container_name(version = nil)
[ role.container_prefix, version || config.version ].compact.join("-")
end
def latest_image_id
docker :image, :ls, *argumentize("--filter", "reference=#{config.latest_image}"), "--format", "'{{.ID}}'"
end
def current_running_container(format:)
pipe \
shell(chain(latest_image_container(format: format), latest_container(format: format))),
[ :head, "-1" ]
end
def latest_image_container(format:)
latest_container format: format, filters: [ "ancestor=$(#{latest_image_id.join(" ")})" ]
end
def latest_container(format:, filters: nil)
docker :ps, "--latest", *format, *filter_args(statuses: ACTIVE_DOCKER_STATUSES), argumentize("--filter", filters)
end
def filter_args(statuses: nil) def filter_args(statuses: nil)
argumentize "--filter", filters(statuses: statuses) argumentize "--filter", filters(statuses: statuses)
end end
def extract_version_from_name
# Extract SHA from "service-role-dest-SHA"
%(while read line; do echo ${line##{role.container_prefix}-}; done)
end
def filters(statuses: nil) def filters(statuses: nil)
[ "label=service=#{config.service}" ].tap do |filters| [ "label=service=#{config.service}" ].tap do |filters|
filters << "label=destination=#{config.destination}" if config.destination filters << "label=destination=#{config.destination}" if config.destination

View File

@@ -5,7 +5,7 @@ module Kamal::Commands::App::Assets
combine \ combine \
make_directory(role.asset_extracted_path), make_directory(role.asset_extracted_path),
[ *docker(:stop, "-t 1", asset_container, "2> /dev/null"), "|| true" ], [ *docker(:stop, "-t 1", asset_container, "2> /dev/null"), "|| true" ],
docker(:run, "--name", asset_container, "--detach", "--rm", config.absolute_image, "sleep 1000000"), docker(:run, "--name", asset_container, "--detach", "--rm", config.latest_image, "sleep 1000000"),
docker(:cp, "-L", "#{asset_container}:#{role.asset_path}/.", role.asset_extracted_path), docker(:cp, "-L", "#{asset_container}:#{role.asset_path}/.", role.asset_extracted_path),
docker(:stop, "-t 1", asset_container), docker(:stop, "-t 1", asset_container),
by: "&&" by: "&&"

View File

@@ -1,6 +1,4 @@
module Kamal::Commands::App::Containers module Kamal::Commands::App::Containers
DOCKER_HEALTH_LOG_FORMAT = "'{{json .State.Health}}'"
def list_containers def list_containers
docker :container, :ls, "--all", *filter_args docker :container, :ls, "--all", *filter_args
end end
@@ -22,11 +20,4 @@ module Kamal::Commands::App::Containers
def remove_containers def remove_containers
docker :container, :prune, "--force", *filter_args docker :container, :prune, "--force", *filter_args
end end
def container_endpoint(version:)
pipe \
container_id_for(container_name: container_name(version)),
xargs(docker(:inspect, "--format", "'{{.NetworkSettings.IPAddress}}{{range $k, $v := .NetworkSettings.Ports}}{{printf \":%s\" $k}}{{break}}{{end}}'")),
[ :sed, "-e", "'s/\\/tcp$//'" ]
end
end end

View File

@@ -1,29 +1,27 @@
module Kamal::Commands::App::Execution module Kamal::Commands::App::Execution
def execute_in_existing_container(*command, interactive: false, env:) def execute_in_existing_container(*command, interactive: false)
docker :exec, docker :exec,
("-it" if interactive), ("-it" if interactive),
*argumentize("--env", env),
container_name, container_name,
*command *command
end end
def execute_in_new_container(*command, interactive: false, env:) def execute_in_new_container(*command, interactive: false)
docker :run, docker :run,
("-it" if interactive), ("-it" if interactive),
"--rm", "--rm",
*role&.env_args(host), *role&.env_args,
*argumentize("--env", env),
*config.volume_args, *config.volume_args,
*role&.option_args, *role&.option_args,
config.absolute_image, config.absolute_image,
*command *command
end end
def execute_in_existing_container_over_ssh(*command, env:) def execute_in_existing_container_over_ssh(*command, host:)
run_over_ssh execute_in_existing_container(*command, interactive: true, env: env), host: host run_over_ssh execute_in_existing_container(*command, interactive: true), host: host
end end
def execute_in_new_container_over_ssh(*command, env:) def execute_in_new_container_over_ssh(*command, host:)
run_over_ssh execute_in_new_container(*command, interactive: true, env: env), host: host run_over_ssh execute_in_new_container(*command, interactive: true), host: host
end end
end end

View File

@@ -7,7 +7,7 @@ module Kamal::Commands::App::Images
docker :image, :prune, "--all", "--force", *filter_args docker :image, :prune, "--all", "--force", *filter_args
end end
def tag_latest_image def tag_current_image_as_latest
docker :tag, config.absolute_image, config.latest_image docker :tag, config.absolute_image, config.latest_image
end end
end end

View File

@@ -1,7 +1,7 @@
module Kamal::Commands::App::Logging module Kamal::Commands::App::Logging
def logs(version: nil, since: nil, lines: nil, grep: nil) def logs(since: nil, lines: nil, grep: nil)
pipe \ pipe \
version ? container_id_for_version(version) : current_running_container_id, current_running_container_id,
"xargs docker logs#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1", "xargs docker logs#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1",
("grep '#{grep}'" if grep) ("grep '#{grep}'" if grep)
end end

View File

@@ -21,7 +21,7 @@ class Kamal::Commands::Auditor < Kamal::Commands::Base
def audit_log_file def audit_log_file
file = [ config.service, config.destination, "audit.log" ].compact.join("-") file = [ config.service, config.destination, "audit.log" ].compact.join("-")
File.join(config.run_directory, file) "#{config.run_directory}/#{file}"
end end
def audit_tags(**details) def audit_tags(**details)

View File

@@ -3,6 +3,7 @@ module Kamal::Commands
delegate :sensitive, :argumentize, to: Kamal::Utils delegate :sensitive, :argumentize, to: Kamal::Utils
DOCKER_HEALTH_STATUS_FORMAT = "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'" DOCKER_HEALTH_STATUS_FORMAT = "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'"
DOCKER_HEALTH_LOG_FORMAT = "'{{json .State.Health}}'"
attr_accessor :config attr_accessor :config
@@ -17,7 +18,7 @@ module Kamal::Commands
elsif config.ssh.proxy && config.ssh.proxy.is_a?(Net::SSH::Proxy::Command) elsif config.ssh.proxy && config.ssh.proxy.is_a?(Net::SSH::Proxy::Command)
cmd << " -o ProxyCommand='#{config.ssh.proxy.command_line_template}'" cmd << " -o ProxyCommand='#{config.ssh.proxy.command_line_template}'"
end end
cmd << " -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ").gsub("'", "'\\\\''")}'" cmd << " -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ")}'"
end end
end end
@@ -70,15 +71,15 @@ module Kamal::Commands
end end
def shell(command) def shell(command)
[ :sh, "-c", "'#{command.flatten.join(" ").gsub("'", "'\\\\''")}'" ] [ :sh, "-c", "'#{command.flatten.join(" ").gsub("'", "'\\''")}'" ]
end end
def docker(*args) def docker(*args)
args.compact.unshift :docker args.compact.unshift :docker
end end
def git(*args, path: nil) def git(*args)
[ :git, *([ "-C", path ] if path), *args.compact ] args.compact.unshift :git
end end
def tags(**details) def tags(**details)

View File

@@ -3,8 +3,6 @@ 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
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
end end

View File

@@ -3,7 +3,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
class BuilderError < StandardError; end class BuilderError < StandardError; end
delegate :argumentize, to: Kamal::Utils delegate :argumentize, to: Kamal::Utils
delegate :args, :secrets, :dockerfile, :target, :local_arch, :local_host, :remote_arch, :remote_host, :cache_from, :cache_to, :ssh, to: :builder_config delegate :args, :secrets, :dockerfile, :local_arch, :local_host, :remote_arch, :remote_host, :cache_from, :cache_to, :ssh, :git_archive?, to: :builder_config
def clean def clean
docker :image, :rm, "--force", config.absolute_image docker :image, :rm, "--force", config.absolute_image
@@ -13,8 +13,18 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
docker :pull, config.absolute_image docker :pull, config.absolute_image
end end
def push
if git_archive?
pipe \
git(:archive, "--format=tar", :HEAD),
build_and_push
else
build_and_push
end
end
def build_options def build_options
[ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh ] [ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_ssh ]
end end
def build_context def build_context
@@ -63,10 +73,6 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
end end
end end
def build_target
argumentize "--target", target if target.present?
end
def build_ssh def build_ssh
argumentize "--ssh", ssh if ssh.present? argumentize "--ssh", ssh if ssh.present?
end end

View File

@@ -1,28 +0,0 @@
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

@@ -13,15 +13,6 @@ class Kamal::Commands::Builder::Multiarch < Kamal::Commands::Builder::Base
docker(:buildx, :ls) docker(:buildx, :ls)
end end
def push
docker :buildx, :build,
"--push",
"--platform", platform_names,
"--builder", builder_name,
*build_options,
build_context
end
private private
def builder_name def builder_name
"kamal-#{config.service}-multiarch" "kamal-#{config.service}-multiarch"
@@ -34,4 +25,13 @@ class Kamal::Commands::Builder::Multiarch < Kamal::Commands::Builder::Base
"linux/amd64,linux/arm64" "linux/amd64,linux/arm64"
end end
end end
def build_and_push
docker :buildx, :build,
"--push",
"--platform", platform_names,
"--builder", builder_name,
*build_options,
build_context
end
end end

View File

@@ -11,10 +11,11 @@ class Kamal::Commands::Builder::Native < Kamal::Commands::Builder::Base
# No-op on native # No-op on native
end end
def push private
combine \ def build_and_push
docker(:build, *build_options, build_context), combine \
docker(:push, config.absolute_image), docker(:build, *build_options, build_context),
docker(:push, config.latest_image) docker(:push, config.absolute_image),
end docker(:push, config.latest_image)
end
end end

View File

@@ -7,10 +7,11 @@ class Kamal::Commands::Builder::Native::Cached < Kamal::Commands::Builder::Nativ
docker :buildx, :rm, builder_name docker :buildx, :rm, builder_name
end end
def push private
docker :buildx, :build, def build_and_push
"--push", docker :buildx, :build,
*build_options, "--push",
build_context *build_options,
end build_context
end
end end

View File

@@ -17,15 +17,6 @@ class Kamal::Commands::Builder::Native::Remote < Kamal::Commands::Builder::Nativ
docker(:buildx, :ls) docker(:buildx, :ls)
end end
def push
docker :buildx, :build,
"--push",
"--platform", platform,
"--builder", builder_name,
*build_options,
build_context
end
private private
def builder_name def builder_name
@@ -56,4 +47,13 @@ class Kamal::Commands::Builder::Native::Remote < Kamal::Commands::Builder::Nativ
def remove_buildx def remove_buildx
docker :buildx, :rm, builder_name docker :buildx, :rm, builder_name
end end
def build_and_push
docker :buildx, :build,
"--push",
"--platform", platform,
"--builder", builder_name,
*build_options,
build_context
end
end end

View File

@@ -19,6 +19,10 @@ class Kamal::Commands::Docker < Kamal::Commands::Base
[ '[ "${EUID:-$(id -u)}" -eq 0 ] || command -v sudo >/dev/null || command -v su >/dev/null' ] [ '[ "${EUID:-$(id -u)}" -eq 0 ] || command -v sudo >/dev/null || command -v su >/dev/null' ]
end end
def create_kamal_network
docker :network, :create, :kamal
end
private private
def get_docker def get_docker
shell \ shell \

View File

@@ -0,0 +1,59 @@
class Kamal::Commands::Healthcheck < Kamal::Commands::Base
def run
primary = config.role(config.primary_role)
docker :run,
"--detach",
"--name", container_name_with_version,
"--publish", "#{exposed_port}:#{config.healthcheck["port"]}",
"--label", "service=#{config.healthcheck_service}",
"-e", "KAMAL_CONTAINER_NAME=\"#{config.healthcheck_service}\"",
*primary.env_args,
*primary.health_check_args,
*config.volume_args,
*primary.option_args,
config.absolute_image,
primary.cmd
end
def status
pipe container_id, xargs(docker(:inspect, "--format", DOCKER_HEALTH_STATUS_FORMAT))
end
def container_health_log
pipe container_id, xargs(docker(:inspect, "--format", DOCKER_HEALTH_LOG_FORMAT))
end
def logs
pipe container_id, xargs(docker(:logs, "--tail", log_lines, "2>&1"))
end
def stop
pipe container_id, xargs(docker(:stop))
end
def remove
pipe container_id, xargs(docker(:container, :rm))
end
private
def container_name_with_version
"#{config.healthcheck_service}-#{config.version}"
end
def container_id
container_id_for(container_name: container_name_with_version)
end
def health_url
"http://localhost:#{exposed_port}#{config.healthcheck["path"]}"
end
def exposed_port
config.healthcheck["exposed_port"]
end
def log_lines
config.healthcheck["log_lines"]
end
end

View File

@@ -9,6 +9,6 @@ class Kamal::Commands::Hook < Kamal::Commands::Base
private private
def hook_file(hook) def hook_file(hook)
File.join(config.hooks_path, hook) "#{config.hooks_path}/#{hook}"
end end
end end

View File

@@ -1,46 +1,43 @@
class Kamal::Commands::Proxy < Kamal::Commands::Base class Kamal::Commands::Proxy < Kamal::Commands::Base
CONTAINER_PORT = 80
delegate :argumentize, :optionize, to: Kamal::Utils delegate :argumentize, :optionize, to: Kamal::Utils
delegate :container_name, to: :proxy_config
attr_reader :proxy_config DEFAULT_IMAGE = "dmcbreen/mproxy:latest"
def initialize(config)
super
@proxy_config = config.proxy
end
def run def run
docker :run, docker :run,
"--name", container_name, "--name", container_name,
"--detach", "--detach",
"--restart", "unless-stopped", "--restart", "unless-stopped",
*proxy_config.publish_args, "--network kamal",
*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/kamal-proxy",
*config.logging_args, *config.logging_args,
*proxy_config.docker_options_args, *label_args,
proxy_config.image *docker_options_args,
image,
*cmd_option_args
end end
def start def start
docker :container, :start, container_name docker :container, :start, container_name
end end
def stop(name: container_name) def stop
docker :container, :stop, name docker :container, :stop, container_name
end end
def start_or_run def start_or_run
combine start, run, by: "||" combine start, run, by: "||"
end end
def deploy(service, target:) def deploy(version)
optionize({ target: target }) docker :exec, container_name, :mproxy, :deploy, version
docker :exec, container_name, "kamal-proxy", :deploy, service, *optionize({ target: target }), *proxy_config.deploy_command_args
end end
def remove(service, target:) def remove(version)
docker :exec, container_name, "kamal-proxy", :remove, service, *optionize({ target: target }) docker :exec, container_name, :mproxy, :remove, version
end end
def info def info
@@ -60,20 +57,60 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base
).join(" "), host: host ).join(" "), host: host
end end
def remove_container(filter: container_filter) def remove_container
docker :container, :prune, "--force", "--filter", filter docker :container, :prune, "--force", "--filter", container_filter
end end
def remove_image(filter: image_filter) def remove_image
docker :image, :prune, "--all", "--force", "--filter", filter docker :image, :prune, "--all", "--force", "--filter", image_filter
end
def port
"#{host_port}:#{CONTAINER_PORT}"
end end
private private
def container_filter def container_filter
"label=org.opencontainers.image.title=kamal-proxy" "label=org.opencontainers.image.title=mproxy"
end end
def image_filter def image_filter
"label=org.opencontainers.image.title=kamal-proxy" "label=org.opencontainers.image.title=mproxy"
end
def publish_args
argumentize "--publish", port unless config.proxy["publish"] == false
end
def label_args
argumentize "--label", labels
end
def labels
config.proxy["labels"] || {}
end
def image
config.proxy.fetch("image") { DEFAULT_IMAGE }
end
def docker_options_args
optionize(config.proxy["options"] || {})
end
def cmd_option_args
optionize cmd_args, with: "="
end
def cmd_args
config.proxy["args"] || {}
end
def host_port
config.proxy["host_port"] || CONTAINER_PORT
end
def container_name
"mproxy"
end end
end end

View File

@@ -20,6 +20,10 @@ class Kamal::Commands::Prune < Kamal::Commands::Base
"while read container_id; do docker rm $container_id; done" "while read container_id; do docker rm $container_id; done"
end end
def healthcheck_containers
docker :container, :prune, "--force", *healthcheck_service_filter
end
private private
def stopped_containers_filters def stopped_containers_filters
[ "created", "exited", "dead" ].flat_map { |status| [ "--filter", "status=#{status}" ] } [ "created", "exited", "dead" ].flat_map { |status| [ "--filter", "status=#{status}" ] }
@@ -35,4 +39,8 @@ class Kamal::Commands::Prune < Kamal::Commands::Base
def service_filter def service_filter
[ "--filter", "label=service=#{config.service}" ] [ "--filter", "label=service=#{config.service}" ]
end end
def healthcheck_service_filter
[ "--filter", "label=service=#{config.healthcheck_service}" ]
end
end end

View File

@@ -88,7 +88,7 @@ class Kamal::Configuration
def all_hosts def all_hosts
(roles + accessories).flat_map(&:hosts).uniq roles.flat_map(&:hosts).uniq
end end
def primary_host def primary_host
@@ -128,11 +128,7 @@ class Kamal::Configuration
end end
def latest_image def latest_image
"#{repository}:#{latest_tag}" "#{repository}:latest"
end
def latest_tag
[ "latest", *destination ].join("-")
end end
def service_with_version def service_with_version
@@ -175,7 +171,7 @@ class Kamal::Configuration
end end
def proxy def proxy
Kamal::Configuration::Proxy.new(config: self) raw_config.proxy || {}
end end
def ssh def ssh
@@ -187,6 +183,14 @@ class Kamal::Configuration
end end
def healthcheck
{ "path" => "/up", "port" => 3000, "max_attempts" => 7, "exposed_port" => 3999, "log_lines" => 50 }.merge(raw_config.healthcheck || {})
end
def healthcheck_service
[ "healthcheck", service, destination ].compact.join("-")
end
def readiness_delay def readiness_delay
raw_config.readiness_delay || 7 raw_config.readiness_delay || 7
end end
@@ -218,25 +222,13 @@ class Kamal::Configuration
def host_env_directory def host_env_directory
File.join(run_directory, "env") "#{run_directory}/env"
end end
def env def env
raw_config.env || {} raw_config.env || {}
end end
def env_tags
@env_tags ||= if (tags = raw_config.env["tags"])
tags.collect { |name, config| Kamal::Configuration::Env::Tag.new(name, config: config) }
else
[]
end
end
def env_tag(name)
env_tags.detect { |t| t.name == name.to_s }
end
def valid? def valid?
ensure_destination_if_required && ensure_required_keys_present && ensure_valid_kamal_version && ensure_retain_containers_valid && ensure_valid_service_name ensure_destination_if_required && ensure_required_keys_present && ensure_valid_kamal_version && ensure_retain_containers_valid && ensure_valid_service_name
@@ -256,7 +248,8 @@ class Kamal::Configuration
sshkit: sshkit.to_h, sshkit: sshkit.to_h,
builder: builder.to_h, builder: builder.to_h,
accessories: raw_config.accessories, accessories: raw_config.accessories,
logging: logging_args logging: logging_args,
healthcheck: healthcheck
}.compact }.compact
end end
@@ -304,7 +297,7 @@ class Kamal::Configuration
end end
def ensure_valid_service_name def ensure_valid_service_name
raise ArgumentError, "Service name can only include alphanumeric characters, hyphens, and underscores" unless raw_config[:service] =~ /^[a-z0-9_-]+$/i raise ArgumentError, "Service name can only include alphanumeric characters, hyphens, and underscores" unless raw_config[:service] =~ /^[a-z0-9_-]+$/
true true
end end
@@ -331,7 +324,7 @@ class Kamal::Configuration
def git_version def git_version
@git_version ||= @git_version ||=
if Kamal::Git.used? if Kamal::Git.used?
if Kamal::Git.uncommitted_changes.present? && !builder.git_clone? if Kamal::Git.uncommitted_changes.present? && !builder.git_archive?
uncommitted_suffix = "_uncommitted_#{SecureRandom.hex(8)}" uncommitted_suffix = "_uncommitted_#{SecureRandom.hex(8)}"
end end
[ Kamal::Git.revision, uncommitted_suffix ].compact.join [ Kamal::Git.revision, uncommitted_suffix ].compact.join

View File

@@ -134,7 +134,7 @@ class Kamal::Configuration::Accessory
end end
def expand_host_path(host_path) def expand_host_path(host_path)
absolute_path?(host_path) ? host_path : File.join(service_data_directory, host_path) absolute_path?(host_path) ? host_path : "#{service_data_directory}/#{host_path}"
end end
def absolute_path?(path) def absolute_path?(path)

View File

@@ -3,8 +3,6 @@ class Kamal::Configuration::Builder
@options = config.raw_config.builder || {} @options = config.raw_config.builder || {}
@image = config.image @image = config.image
@server = config.registry["server"] @server = config.registry["server"]
@service = config.service
@destination = config.destination
valid? valid?
end end
@@ -41,12 +39,8 @@ class Kamal::Configuration::Builder
@options["dockerfile"] || "Dockerfile" @options["dockerfile"] || "Dockerfile"
end end
def target
@options["target"]
end
def context def context
@options["context"] || "." @options["context"] || (git_archive? ? "-" : ".")
end end
def local_arch def local_arch
@@ -91,23 +85,10 @@ class Kamal::Configuration::Builder
@options["ssh"] @options["ssh"]
end end
def git_clone? def git_archive?
Kamal::Git.used? && @options["context"].nil? Kamal::Git.used? && @options["context"].nil?
end end
def clone_directory
@clone_directory ||= File.join Dir.tmpdir, "kamal-clones", [ @service, pwd_sha ].compact.join("-")
end
def build_directory
@build_directory ||=
if git_clone?
File.join clone_directory, repo_basename, repo_relative_pwd
else
"."
end
end
private private
def valid? def valid?
if @options["cache"] && @options["cache"]["type"] if @options["cache"] && @options["cache"]["type"]
@@ -138,16 +119,4 @@ class Kamal::Configuration::Builder
def cache_to_config_for_registry def cache_to_config_for_registry
[ "type=registry", @options["cache"]&.fetch("options", nil), "ref=#{cache_image_ref}" ].compact.join(",") [ "type=registry", @options["cache"]&.fetch("options", nil), "ref=#{cache_image_ref}" ].compact.join(",")
end end
def repo_basename
File.basename(Kamal::Git.root)
end
def repo_relative_pwd
Dir.pwd.delete_prefix(Kamal::Git.root)
end
def pwd_sha
Digest::SHA256.hexdigest(Dir.pwd)[0..12]
end
end end

View File

@@ -4,7 +4,7 @@ class Kamal::Configuration::Env
def self.from_config(config:, secrets_file: nil) def self.from_config(config:, secrets_file: nil)
secrets_keys = config.fetch("secret", []) secrets_keys = config.fetch("secret", [])
clear = config.fetch("clear", config.key?("secret") || config.key?("tags") ? {} : config) clear = config.fetch("clear", config.key?("secret") ? {} : config)
new clear: clear, secrets_keys: secrets_keys, secrets_file: secrets_file new clear: clear, secrets_keys: secrets_keys, secrets_file: secrets_file
end end

View File

@@ -1,12 +0,0 @@
class Kamal::Configuration::Env::Tag
attr_reader :name, :config
def initialize(name, config:)
@name = name
@config = config
end
def env
Kamal::Configuration::Env.from_config(config: config)
end
end

View File

@@ -1,50 +0,0 @@
class Kamal::Configuration::Proxy
DEFAULT_HTTP_PORT = 80
DEFAULT_HTTPS_PORT = 443
DEFAULT_IMAGE = "basecamp/kamal-proxy:latest"
delegate :argumentize, :optionize, to: Kamal::Utils
def initialize(config:)
@options = config.raw_config.proxy || {}
end
def image
options.fetch("image", DEFAULT_IMAGE)
end
def debug?
!!options[:debug]
end
def http_port
options.fetch(:http_port, DEFAULT_HTTP_PORT)
end
def https_port
options.fetch(:http_port, DEFAULT_HTTPS_PORT)
end
def container_name
"kamal-proxy"
end
def docker_options_args
optionize(options.fetch("options", {}))
end
def publish_args
argumentize "--publish", [ *("#{http_port}:#{DEFAULT_HTTP_PORT}" if http_port), *("#{https_port}:#{DEFAULT_HTTPS_PORT}" if https_port) ]
end
def deploy_options
options.fetch(:deploy, {})
end
def deploy_command_args
optionize deploy_options
end
private
attr_accessor :options
end

View File

@@ -6,7 +6,6 @@ class Kamal::Configuration::Role
def initialize(name, config:) def initialize(name, config:)
@name, @config = name.inquiry, config @name, @config = name.inquiry, config
@tagged_hosts ||= extract_tagged_hosts_from_config
end end
def primary_host def primary_host
@@ -14,11 +13,11 @@ class Kamal::Configuration::Role
end end
def hosts def hosts
tagged_hosts.keys @hosts ||= extract_hosts_from_config
end end
def env_tags(host) def port
tagged_hosts.fetch(host).collect { |tag| config.env_tag(tag) } specializations["port"] || config.port || "3000"
end end
def cmd def cmd
@@ -54,13 +53,12 @@ class Kamal::Configuration::Role
end end
def env(host) def env
@envs ||= {} @env ||= base_env.merge(specialized_env)
@envs[host] ||= [ base_env, specialized_env, *env_tags(host).map(&:env) ].reduce(:merge)
end end
def env_args(host) def env_args
env(host).args env.args
end end
def asset_volume_args def asset_volume_args
@@ -68,6 +66,23 @@ class Kamal::Configuration::Role
end end
def health_check_args
if health_check_cmd.present?
optionize({ "health-cmd" => health_check_cmd, "health-interval" => health_check_interval })
else
[]
end
end
def health_check_cmd
health_check_options["cmd"] || http_health_check(port: health_check_options["port"], path: health_check_options["path"])
end
def health_check_interval
health_check_options["interval"] || "1s"
end
def running_proxy? def running_proxy?
if specializations["proxy"].nil? if specializations["proxy"].nil?
primary? primary?
@@ -114,24 +129,7 @@ class Kamal::Configuration::Role
end end
private private
attr_accessor :config, :tagged_hosts attr_accessor :config
def extract_tagged_hosts_from_config
{}.tap do |tagged_hosts|
extract_hosts_from_config.map do |host_config|
if host_config.is_a?(Hash)
raise ArgumentError, "Multiple hosts found: #{host_config.inspect}" unless host_config.size == 1
host, tags = host_config.first
tagged_hosts[host] = Array(tags)
elsif host_config.is_a?(String) || host_config.is_a?(Symbol)
tagged_hosts[host_config] = []
else
raise ArgumentError, "Invalid host config: #{host_config.inspect}"
end
end
end
end
def extract_hosts_from_config def extract_hosts_from_config
if config.servers.is_a?(Array) if config.servers.is_a?(Array)
@@ -171,4 +169,16 @@ class Kamal::Configuration::Role
config: config.env, config: config.env,
secrets_file: File.join(config.host_env_directory, "roles", "#{container_prefix}.env") secrets_file: File.join(config.host_env_directory, "roles", "#{container_prefix}.env")
end end
def http_health_check(port:, path:)
"curl -f #{URI.join("http://localhost:#{port}", path)} || exit 1" if path.present? || port.present?
end
def health_check_options
@health_check_options ||= begin
options = specializations["healthcheck"] || {}
options = config.healthcheck.merge(options) if running_proxy?
options
end
end
end end

View File

@@ -24,13 +24,6 @@ class Kamal::EnvFile
# Escape a value to make it safe to dump in a docker file. # Escape a value to make it safe to dump in a docker file.
def escape_docker_env_file_value(value) def escape_docker_env_file_value(value)
# keep non-ascii(UTF-8) characters as it is
value.to_s.scan(/[\x00-\x7F]+|[^\x00-\x7F]+/).map do |part|
part.ascii_only? ? escape_docker_env_file_ascii_value(part) : part
end.join
end
def escape_docker_env_file_ascii_value(value)
# Doublequotes are treated literally in docker env files # Doublequotes are treated literally in docker env files
# so remove leading and trailing ones and unescape any others # so remove leading and trailing ones and unescape any others
value.to_s.dump[1..-2].gsub(/\\"/, "\"") value.to_s.dump[1..-2].gsub(/\\"/, "\"")

View File

@@ -16,8 +16,4 @@ module Kamal::Git
def uncommitted_changes def uncommitted_changes
`git status --porcelain`.strip `git status --porcelain`.strip
end end
def root
`git rev-parse --show-toplevel`.strip
end
end end

View File

@@ -103,39 +103,3 @@ class SSHKit::Backend::Netssh
prepend LimitConcurrentStartsInstance prepend LimitConcurrentStartsInstance
end end
class SSHKit::Runner::Parallel
# SSHKit joins the threads in sequence and fails on the first error it encounters, which means that we wait threads
# before the first failure to complete but not for ones after.
#
# We'll patch it to wait for them all to complete, and to record all the threads that errored so we can see when a
# problem occurs on multiple hosts.
module CompleteAll
def execute
threads = hosts.map do |host|
Thread.new(host) do |h|
backend(h, &block).run
rescue ::StandardError => e
e2 = SSHKit::Runner::ExecuteError.new e
raise e2, "Exception while executing #{host.user ? "as #{host.user}@" : "on host "}#{host}: #{e.message}"
end
end
exceptions = []
threads.each do |t|
begin
t.join
rescue SSHKit::Runner::ExecuteError => e
exceptions << e
end
end
if exceptions.one?
raise exceptions.first
elsif exceptions.many?
raise exceptions.first, [ "Exceptions on #{exceptions.count} hosts:", exceptions.map(&:message) ].join("\n")
end
end
end
prepend CompleteAll
end

View File

@@ -66,15 +66,12 @@ module Kamal::Utils
Array(filters).select do |filter| Array(filters).select do |filter|
matches += Array(items).select do |item| matches += Array(items).select do |item|
# Only allow * for a wildcard # Only allow * for a wildcard
pattern = Regexp.escape(filter).gsub('\*', ".*")
# items are roles or hosts # items are roles or hosts
File.fnmatch(filter, item.to_s, File::FNM_EXTGLOB) (item.respond_to?(:name) ? item.name : item).match(/^#{pattern}$/)
end end
end end
matches.uniq matches
end
def stable_sort!(elements, &block)
elements.sort_by!.with_index { |element, index| [ block.call(element), index ] }
end end
end end

View File

@@ -1,3 +1,3 @@
module Kamal module Kamal
VERSION = "1.5.2" VERSION = "1.4.0"
end end

View File

@@ -76,10 +76,7 @@ class CliAccessoryTest < CliTestCase
end end
test "details" do test "details" do
run_command("details", "mysql").tap do |output| assert_match "docker ps --filter label=service=app-mysql", run_command("details", "mysql")
assert_match "docker ps --filter label=service=app-mysql", output
assert_match "Accessory mysql Host: 1.1.1.3", output
end
end end
test "details with non-existent accessory" do test "details with non-existent accessory" do
@@ -88,8 +85,6 @@ class CliAccessoryTest < CliTestCase
test "details with all" do test "details with all" do
run_command("details", "all").tap do |output| run_command("details", "all").tap do |output|
assert_match "Accessory mysql Host: 1.1.1.3", output
assert_match "Accessory redis Host: 1.1.1.2", output
assert_match "docker ps --filter label=service=app-mysql", output assert_match "docker ps --filter label=service=app-mysql", output
assert_match "docker ps --filter label=service=app-redis", output assert_match "docker ps --filter label=service=app-redis", output
end end

View File

@@ -5,7 +5,7 @@ class CliAppTest < CliTestCase
stub_running stub_running
run_command("boot").tap do |output| run_command("boot").tap do |output|
assert_match "docker tag dhh/app:latest dhh/app:latest", output assert_match "docker tag dhh/app:latest dhh/app:latest", output
assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname 1.1.1.1-[0-9a-f]{12} /, output assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output
assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
end end
end end
@@ -18,18 +18,13 @@ class CliAppTest < CliTestCase
.returns("12345678") # running version .returns("12345678") # running version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --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=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--filter", "status=running", "--filter", "status=restarting", "--latest", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("123") # old version .returns("123") # old version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--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
run_command("boot").tap do |output| run_command("boot").tap do |output|
assert_match /Renaming container .* to .* as already deployed on 1.1.1.1/, output # Rename assert_match /Renaming container .* to .* as already deployed on 1.1.1.1/, output # Rename
assert_match /docker rename app-web-latest app-web-latest_replaced_[0-9a-f]{16}/, output assert_match /docker rename app-web-latest app-web-latest_replaced_[0-9a-f]{16}/, output
assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname 1.1.1.1-[0-9a-f]{12} /, output assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output
assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
end end
ensure ensure
@@ -46,123 +41,37 @@ class CliAppTest < CliTestCase
run_command("boot", config: :with_boot_strategy) run_command("boot", config: :with_boot_strategy)
end end
test "boot errors don't leave lock in place" do test "boot errors leave lock in place" do
Kamal::Cli::App.any_instance.expects(:using_version).raises(RuntimeError) Kamal::Cli::App.any_instance.expects(:using_version).raises(RuntimeError)
assert_not KAMAL.holding_lock? assert_not KAMAL.holding_lock?
assert_raises(RuntimeError) do assert_raises(RuntimeError) do
stderred { run_command("boot") } stderred { run_command("boot") }
end end
assert_not KAMAL.holding_lock? assert KAMAL.holding_lock?
end end
test "boot with assets" do test "boot with assets" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", raise_on_non_zero_exit: false) Object.any_instance.stubs(:sleep)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", raise_on_non_zero_exit: false)
.returns("12345678") # running version .returns("12345678") # running version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --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=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--filter", "status=running", "--filter", "status=restarting", "--latest", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("123").twice # old version .returns("123").twice # old version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--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
run_command("boot", config: :with_assets).tap do |output| run_command("boot", config: :with_assets).tap do |output|
assert_match "docker tag dhh/app:latest dhh/app:latest", output assert_match "docker tag dhh/app:latest dhh/app:latest", output
assert_match "/usr/bin/env mkdir -p .kamal/assets/volumes/app-web-latest ; cp -rnT .kamal/assets/extracted/app-web-latest .kamal/assets/volumes/app-web-latest ; cp -rnT .kamal/assets/extracted/app-web-latest .kamal/assets/volumes/app-web-123 || true ; cp -rnT .kamal/assets/extracted/app-web-123 .kamal/assets/volumes/app-web-latest || true", output assert_match "/usr/bin/env mkdir -p .kamal/assets/volumes/app-web-latest ; cp -rnT .kamal/assets/extracted/app-web-latest .kamal/assets/volumes/app-web-latest ; cp -rnT .kamal/assets/extracted/app-web-latest .kamal/assets/volumes/app-web-123 || true ; cp -rnT .kamal/assets/extracted/app-web-123 .kamal/assets/volumes/app-web-latest || true", output
assert_match "/usr/bin/env mkdir -p .kamal/assets/extracted/app-web-latest && docker stop -t 1 app-web-assets 2> /dev/null || true && docker run --name app-web-assets --detach --rm dhh/app:latest sleep 1000000 && docker cp -L app-web-assets:/public/assets/. .kamal/assets/extracted/app-web-latest && docker stop -t 1 app-web-assets", output assert_match "/usr/bin/env mkdir -p .kamal/assets/extracted/app-web-latest && docker stop -t 1 app-web-assets 2> /dev/null || true && docker run --name app-web-assets --detach --rm dhh/app:latest sleep 1000000 && docker cp -L app-web-assets:/public/assets/. .kamal/assets/extracted/app-web-latest && docker stop -t 1 app-web-assets", output
assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname 1.1.1.1-[0-9a-f]{12} /, output assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output
assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
assert_match "/usr/bin/env find .kamal/assets/extracted -maxdepth 1 -name 'app-web-*' ! -name app-web-latest -exec rm -rf \"{}\" + ; find .kamal/assets/volumes -maxdepth 1 -name 'app-web-*' ! -name app-web-latest -exec rm -rf \"{}\" +", output assert_match "/usr/bin/env find .kamal/assets/extracted -maxdepth 1 -name 'app-web-*' ! -name app-web-latest -exec rm -rf \"{}\" + ; find .kamal/assets/volumes -maxdepth 1 -name 'app-web-*' ! -name app-web-latest -exec rm -rf \"{}\" +", output
end end
end end
test "boot with host tags" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", raise_on_non_zero_exit: false)
.returns("12345678") # running version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --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=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("123") # old version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--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
run_command("boot", config: :with_env_tags).tap do |output|
assert_match "docker tag dhh/app:latest dhh/app:latest", output
assert_match %r{docker run --detach --restart unless-stopped --name app-web-latest --hostname 1.1.1.1-[0-9a-f]{12} -e KAMAL_CONTAINER_NAME="app-web-latest" -e KAMAL_VERSION="latest" --env-file .kamal/env/roles/app-web.env --env TEST="root" --env EXPERIMENT="disabled" --env SITE="site1"}, output
assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
end
end
test "boot with web barrier opened" do
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--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
run_command("boot", config: :with_roles, host: nil).tap do |output|
assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.3...", output
assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.4...", output
assert_match "First web container is healthy, booting workers on 1.1.1.3", output
assert_match "First web container is healthy, booting workers on 1.1.1.4", output
end
end
test "boot with web barrier closed" do
Thread.report_on_exception = false
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", raise_on_non_zero_exit: false)
.returns("abcdef123456")
.twice # web container id
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-workers-latest$", "--quiet", raise_on_non_zero_exit: false)
.returns("abcdef123456")
.twice # worker container id
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with { |*args| args[0..1] == [ :sh, "-c" ] }.returns("123").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--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.stubs(:execute).returns("")
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
run_command("boot", config: :with_roles, host: nil, allowed_error_message: "Deploy failed").tap do |output|
assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.3...", output
assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.4...", output
assert_match "First web container is unhealthy, not booting workers on 1.1.1.3", output
assert_match "First web container is unhealthy, not booting workers on 1.1.1.4", output
end
end
ensure
Thread.report_on_exception = true
end
test "start" do test "start" do
# SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --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=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("123") # current version
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
run_command("start").tap do |output| run_command("start").tap do |output|
assert_match "docker start app-web-999", output assert_match "docker start app-web-999", output
end end
@@ -170,18 +79,14 @@ class CliAppTest < CliTestCase
test "stop" do test "stop" do
run_command("stop").tap do |output| run_command("stop").tap do |output|
assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop", output assert_match "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker stop", output
end end
end end
test "stale_containers" do test "stale_containers" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("12345678\n87654321\n") .returns("12345678\n87654321")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --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=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("12345678\n")
run_command("stale_containers").tap do |output| run_command("stale_containers").tap do |output|
assert_match /Detected stale container for role web with version 87654321/, output assert_match /Detected stale container for role web with version 87654321/, output
@@ -191,11 +96,7 @@ class CliAppTest < CliTestCase
test "stop stale_containers" do test "stop stale_containers" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("12345678\n87654321\n") .returns("12345678\n87654321")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --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=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("12345678\n")
run_command("stale_containers", "--stop").tap do |output| run_command("stale_containers", "--stop").tap do |output|
assert_match /Stopping stale container for role web with version 87654321/, output assert_match /Stopping stale container for role web with version 87654321/, output
@@ -211,7 +112,7 @@ class CliAppTest < CliTestCase
test "remove" do test "remove" do
run_command("remove").tap do |output| run_command("remove").tap do |output|
assert_match /#{Regexp.escape("sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop")}/, output assert_match /#{Regexp.escape("docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker stop")}/, output
assert_match /#{Regexp.escape("docker container prune --force --filter label=service=app")}/, output assert_match /#{Regexp.escape("docker container prune --force --filter label=service=app")}/, output
assert_match /#{Regexp.escape("docker image prune --all --force --filter label=service=app")}/, output assert_match /#{Regexp.escape("docker image prune --all --force --filter label=service=app")}/, output
end end
@@ -243,7 +144,7 @@ class CliAppTest < CliTestCase
test "exec with reuse" do test "exec with reuse" do
run_command("exec", "--reuse", "ruby -v").tap do |output| run_command("exec", "--reuse", "ruby -v").tap do |output|
assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --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=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output # Get current version assert_match "docker ps --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done", output # Get current version
assert_match "docker exec app-web-999 ruby -v", output assert_match "docker exec app-web-999 ruby -v", output
end end
end end
@@ -262,7 +163,7 @@ class CliAppTest < CliTestCase
.with("ssh -t root@1.1.1.1 -p 22 'docker exec -it app-web-999 ruby -v'") .with("ssh -t root@1.1.1.1 -p 22 'docker exec -it app-web-999 ruby -v'")
run_command("exec", "-i", "--reuse", "ruby -v").tap do |output| run_command("exec", "-i", "--reuse", "ruby -v").tap do |output|
assert_match "Get current version of running container...", output assert_match "Get current version of running container...", output
assert_match "Running /usr/bin/env sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --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=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done on 1.1.1.1", output assert_match "Running docker ps --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done on 1.1.1.1", output
assert_match "Launching interactive command with version 999 via SSH from existing container on 1.1.1.1...", output assert_match "Launching interactive command with version 999 via SSH from existing container on 1.1.1.1...", output
end end
end end
@@ -281,61 +182,39 @@ class CliAppTest < CliTestCase
test "logs" do test "logs" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec) SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.1 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1| xargs docker logs --timestamps --tail 10 2>&1'") .with("ssh -t root@1.1.1.1 'docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest| xargs docker logs --timestamps --tail 10 2>&1'")
assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --tail 100 2>&1", run_command("logs") assert_match "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --tail 100 2>&1", run_command("logs")
end end
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 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 10 --follow 2>&1'") .with("ssh -t root@1.1.1.1 -p 22 'docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --tail 10 --follow 2>&1'")
assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 10 --follow 2>&1", run_command("logs", "--follow") assert_match "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --tail 10 --follow 2>&1", run_command("logs", "--follow")
end end
test "version" do test "version" do
run_command("version").tap do |output| run_command("version").tap do |output|
assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --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=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output assert_match "docker ps --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done", output
end end
end end
test "version through main" do test "version through main" do
stdouted { Kamal::Cli::Main.start([ "app", "version", "-c", "test/fixtures/deploy_with_accessories.yml", "--hosts", "1.1.1.1" ]) }.tap do |output| stdouted { Kamal::Cli::Main.start([ "app", "version", "-c", "test/fixtures/deploy_with_accessories.yml", "--hosts", "1.1.1.1" ]) }.tap do |output|
assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --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=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output assert_match "docker ps --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done", output
end
end
test "long hostname" do
stub_running
hostname = "this-hostname-is-really-unacceptably-long-to-be-honest.example.com"
stdouted { Kamal::Cli::App.start([ "boot", "-c", "test/fixtures/deploy_with_uncommon_hostnames.yml", "--hosts", hostname ]) }.tap do |output|
assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname this-hostname-is-really-unacceptably-long-to-be-hon-[0-9a-f]{12} /, output
end
end
test "hostname is trimmed if will end with a period" do
stub_running
hostname = "this-hostname-with-random-part-is-too-long.example.com"
stdouted { Kamal::Cli::App.start([ "boot", "-c", "test/fixtures/deploy_with_uncommon_hostnames.yml", "--hosts", hostname ]) }.tap do |output|
assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname this-hostname-with-random-part-is-too-long.example-[0-9a-f]{12} /, output
end end
end end
private private
def run_command(*command, config: :with_accessories, host: "1.1.1.1", allowed_error_message: nil) def run_command(*command, config: :with_accessories)
stdouted do stdouted { Kamal::Cli::App.start([ *command, "-c", "test/fixtures/deploy_#{config}.yml", "--hosts", "1.1.1.1" ]) }
Kamal::Cli::App.start([ *command, "-c", "test/fixtures/deploy_#{config}.yml", *([ "--hosts", host ] if host) ])
rescue SSHKit::Runner::ExecuteError => e
raise e unless allowed_error_message && e.message.include?(allowed_error_message)
end
end end
def stub_running def stub_running
Object.any_instance.stubs(:sleep)
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version
end end
end end

View File

@@ -9,137 +9,32 @@ class CliBuildTest < CliTestCase
end end
test "push" do test "push" do
with_build_directory do |build_directory|
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" }
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|
assert_hook_ran "pre-build", output, **hook_variables
assert_match /Cloning repo into build directory/, output
assert_match /git -C #{Dir.tmpdir}\/kamal-clones\/app-#{pwd_sha} clone #{Dir.pwd}/, output
assert_match /docker --version && docker buildx version/, output
assert_match /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 \. as .*@localhost/, output
end
end
end
test "push resetting clone" do
with_build_directory do |build_directory|
stub_setup
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)
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :remote, "set-url", :origin, Dir.pwd)
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :fetch, :origin)
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :reset, "--hard", Kamal::Git.revision)
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :clean, "-fdx")
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", ".")
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|
assert_match /Cloning repo into build directory/, output
assert_match /Resetting local clone/, output
end
end
end
test "push without clone" do
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" }
run_command("push", "--verbose", fixture: :without_clone).tap do |output| run_command("push").tap do |output|
assert_no_match /Cloning repo into build directory/, output
assert_hook_ran "pre-build", output, **hook_variables assert_hook_ran "pre-build", output, **hook_variables
assert_match /docker --version && docker buildx version/, output assert_match /docker --version && docker buildx version/, output
assert_match /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 . as .*@localhost/, output assert_match /git archive -tar HEAD | 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 - as .*@localhost/, output
end
end
test "push with corrupt clone" do
with_build_directory do |build_directory|
stub_setup
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
end end
test "push without builder" do test "push without builder" do
with_build_directory do |build_directory| stub_setup
stub_setup SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, "--version", "&&", :docker, :buildx, "version")
SSHKit::Backend::Abstract.any_instance.expects(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, "--version", "&&", :docker, :buildx, "version") .with(:docker, :buildx, :create, "--use", "--name", "kamal-app-multiarch")
SSHKit::Backend::Abstract.any_instance.expects(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, :buildx, :create, "--use", "--name", "kamal-app-multiarch") .with { |*args| p args[0..6]; args[0..6] == [ :git, :archive, "--format=tar", :HEAD, "|", :docker, :buildx ] }
.raises(SSHKit::Command::Failed.new("no builder"))
.then
.returns(true)
SSHKit::Backend::Abstract.any_instance.expects(:execute) run_command("push").tap do |output|
.with { |*args| args[0..1] == [ :docker, :buildx ] } assert_match /Missing compatible builder, so creating a new one first/, output
.raises(SSHKit::Command::Failed.new("no builder"))
.then
.returns(true)
SSHKit::Backend::Abstract.any_instance.expects(:execute).with { |*args| args.first.start_with?("git") }
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|
assert_match /WARN Missing compatible builder, so creating a new one first/, output
end
end end
end end
@@ -156,8 +51,7 @@ class CliBuildTest < CliTestCase
test "push pre-build hook failure" do test "push pre-build hook failure" do
fail_hook("pre-build") fail_hook("pre-build")
error = assert_raises(Kamal::Cli::HookError) { run_command("push") } assert_raises(Kamal::Cli::HookError) { run_command("push") }
assert_equal "Hook `pre-build` failed:\nfailed", error.message
assert @executions.none? { |args| args[0..2] == [ :docker, :buildx, :build ] } assert @executions.none? { |args| args[0..2] == [ :docker, :buildx, :build ] }
end end
@@ -184,14 +78,6 @@ class CliBuildTest < CliTestCase
end end
end end
test "create remote with custom ports" do
run_command("create", fixture: :with_remote_builder_and_custom_ports).tap do |output|
assert_match "Running /usr/bin/env true on 1.1.1.5", output
assert_match "docker context create kamal-app-native-remote-amd64 --description 'kamal-app-native-remote amd64 native host' --docker 'host=ssh://app@1.1.1.5:2122'", output
assert_match "docker buildx create --name kamal-app-native-remote kamal-app-native-remote-amd64 --platform linux/amd64", output
end
end
test "create with error" do test "create with error" do
stub_setup stub_setup
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
@@ -231,17 +117,4 @@ class CliBuildTest < CliTestCase
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args[0..1] == [ :docker, :buildx ] } .with { |*args| args[0..1] == [ :docker, :buildx ] }
end end
def with_build_directory
build_directory = File.join Dir.tmpdir, "kamal-clones", "app-#{pwd_sha}", "kamal"
FileUtils.mkdir_p build_directory
FileUtils.touch File.join build_directory, "Dockerfile"
yield build_directory + "/"
ensure
FileUtils.rm_rf build_directory
end
def pwd_sha
Digest::SHA256.hexdigest(Dir.pwd)[0..12]
end
end end

View File

@@ -38,7 +38,7 @@ class CliTestCase < ActiveSupport::TestCase
.with { |arg1, arg2| arg1 == :rm && arg2 == ".kamal/locks/app/details" } .with { |arg1, arg2| arg1 == :rm && arg2 == ".kamal/locks/app/details" }
end end
def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, subcommand: nil, runtime: false) def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, subcommand: nil, runtime: nil)
performer = `whoami`.strip performer = `whoami`.strip
assert_match "Running the #{hook} hook...\n", output assert_match "Running the #{hook} hook...\n", output
@@ -52,7 +52,7 @@ class CliTestCase < ActiveSupport::TestCase
KAMAL_HOSTS=\"#{hosts}\"\s KAMAL_HOSTS=\"#{hosts}\"\s
KAMAL_COMMAND=\"#{command}\"\s KAMAL_COMMAND=\"#{command}\"\s
#{"KAMAL_SUBCOMMAND=\\\"#{subcommand}\\\"\\s" if subcommand} #{"KAMAL_SUBCOMMAND=\\\"#{subcommand}\\\"\\s" if subcommand}
#{"KAMAL_RUNTIME=\\\"\\d+\\\"\\s" if runtime} #{"KAMAL_RUNTIME=\\\"#{runtime}\\\"\\s" if runtime}
;\s/usr/bin/env\s\.kamal/hooks/#{hook} }x ;\s/usr/bin/env\s\.kamal/hooks/#{hook} }x
assert_match expected, output assert_match expected, output

View File

@@ -0,0 +1,82 @@
require_relative "cli_test_case"
class CliHealthcheckTest < CliTestCase
test "perform" do
# Prevent expected failures from outputting to terminal
Thread.report_on_exception = false
Kamal::Cli::Healthcheck::Poller.stubs(:sleep) # No sleeping when retrying
Kamal::Configuration.any_instance.stubs(:run_id).returns("12345678901234567890123456789012")
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :stop, raise_on_non_zero_exit: false)
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, :run, "--detach", "--name", "healthcheck-app-999", "--publish", "3999:3000", "--label", "service=healthcheck-app", "-e", "KAMAL_CONTAINER_NAME=\"healthcheck-app\"", "--env-file", ".kamal/env/roles/app-web.env", "--health-cmd", "\"curl -f http://localhost:3000/up || exit 1\"", "--health-interval", "\"1s\"", "dhh/app:999")
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :container, :rm, raise_on_non_zero_exit: false)
# Fail twice to test retry logic
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("starting")
.then
.returns("unhealthy")
.then
.returns("healthy")
run_command("perform").tap do |output|
assert_match "container not ready (starting), retrying in 1s (attempt 1/7)...", output
assert_match "container not ready (unhealthy), retrying in 2s (attempt 2/7)...", output
assert_match "Container is healthy!", output
end
end
test "perform failing to become healthy" do
# Prevent expected failures from outputting to terminal
Thread.report_on_exception = false
Kamal::Cli::Healthcheck::Poller.stubs(:sleep) # No sleeping when retrying
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :stop, raise_on_non_zero_exit: false)
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, :run, "--detach", "--name", "healthcheck-app-999", "--publish", "3999:3000", "--label", "service=healthcheck-app", "-e", "KAMAL_CONTAINER_NAME=\"healthcheck-app\"", "--env-file", ".kamal/env/roles/app-web.env", "--health-cmd", "\"curl -f http://localhost:3000/up || exit 1\"", "--health-interval", "\"1s\"", "dhh/app:999")
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :container, :rm, raise_on_non_zero_exit: false)
# Continually report unhealthy
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("unhealthy")
# Capture logs when failing
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :logs, "--tail", 50, "2>&1")
.returns("some log output")
# Capture container health log when failing
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_pretty_json)
.with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{json .State.Health}}'")
.returns('{"Status":"unhealthy","Log":[{"ExitCode": 1,"Output": "/bin/sh: 1: curl: not found\n"}]}"')
exception = assert_raises do
run_command("perform")
end
assert_match "container not ready (unhealthy)", exception.message
end
test "raises an exception if primary does not have a proxy" do
SSHKit::Backend::Abstract.any_instance.expects(:execute).never
exception = assert_raises do
run_command("perform", config_file: "test/fixtures/deploy_workers_only.yml")
end
assert_equal "The primary host is not configured to run a proxy", exception.message
end
private
def run_command(*command, config_file: "test/fixtures/deploy_with_accessories.yml")
stdouted { Kamal::Cli::Healthcheck.start([ *command, "-c", config_file ]) }
end
end

View File

@@ -5,13 +5,13 @@ class CliMainTest < CliTestCase
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:main:envify", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:env:push", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options)
Kamal::Cli::Main.any_instance.expects(:deploy) Kamal::Cli::Main.any_instance.expects(:deploy)
run_command("setup").tap do |output| run_command("setup").tap do |output|
assert_match /Ensure Docker is installed.../, output assert_match /Ensure Docker is installed.../, output
assert_match /Evaluate and push env files.../, output assert_match /Push env files.../, output
end end
end end
@@ -19,24 +19,26 @@ class CliMainTest < CliTestCase
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:main:envify", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:env:push", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options)
# deploy # deploy
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:pull", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:pull", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options)
run_command("setup", "--skip_push").tap do |output| run_command("setup", "--skip_push").tap do |output|
assert_match /Ensure Docker is installed.../, output assert_match /Ensure Docker is installed.../, output
assert_match /Evaluate and push env files.../, output assert_match /Push env files.../, output
# deploy # deploy
assert_match /Acquiring the deploy lock/, output assert_match /Acquiring the deploy lock/, output
assert_match /Log into image registry/, output assert_match /Log into image registry/, output
assert_match /Pull app image/, output assert_match /Pull app image/, output
assert_match /Ensure proxy is running/, output assert_match /Ensure proxy is running/, output
assert_match /Ensure app can pass healthcheck/, output
assert_match /Detect stale containers/, output assert_match /Detect stale containers/, output
assert_match /Prune old containers and images/, output assert_match /Prune old containers and images/, output
assert_match /Releasing the deploy lock/, output assert_match /Releasing the deploy lock/, output
@@ -44,11 +46,12 @@ class CliMainTest < CliTestCase
end end
test "deploy" do test "deploy" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, "verbose" => true } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options)
@@ -56,15 +59,16 @@ class CliMainTest < CliTestCase
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", command: "deploy" } hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "deploy" }
run_command("deploy", "--verbose").tap do |output| run_command("deploy").tap do |output|
assert_hook_ran "pre-connect", output, **hook_variables assert_hook_ran "pre-connect", output, **hook_variables
assert_match /Log into image registry/, output assert_match /Log into image registry/, output
assert_match /Build and push app image/, output assert_match /Build and push app image/, output
assert_hook_ran "pre-deploy", output, **hook_variables assert_hook_ran "pre-deploy", output, **hook_variables
assert_match /Ensure proxy is running/, output assert_match /Ensure proxy is running/, output
assert_match /Ensure app can pass healthcheck/, output
assert_match /Detect stale containers/, output assert_match /Detect stale containers/, output
assert_match /Prune old containers and images/, output assert_match /Prune old containers and images/, output
assert_hook_ran "post-deploy", output, **hook_variables, runtime: true assert_hook_ran "post-deploy", output, **hook_variables, runtime: 0
end end
end end
@@ -74,6 +78,7 @@ class CliMainTest < CliTestCase
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:pull", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:pull", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options)
@@ -83,6 +88,7 @@ class CliMainTest < CliTestCase
assert_match /Log into image registry/, output assert_match /Log into image registry/, output
assert_match /Pull app image/, output assert_match /Pull app image/, output
assert_match /Ensure proxy is running/, output assert_match /Ensure proxy is running/, output
assert_match /Ensure app can pass healthcheck/, output
assert_match /Detect stale containers/, output assert_match /Detect stale containers/, output
assert_match /Prune old containers and images/, output assert_match /Prune old containers and images/, output
assert_match /Releasing the deploy lock/, output assert_match /Releasing the deploy lock/, output
@@ -92,9 +98,6 @@ class CliMainTest < CliTestCase
test "deploy when locked" do test "deploy when locked" do
Thread.report_on_exception = false Thread.report_on_exception = false
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
Dir.stubs(:chdir)
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args == [ :mkdir, "-p", ".kamal" ] } .with { |*args| args == [ :mkdir, "-p", ".kamal" ] }
@@ -108,14 +111,6 @@ 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
@@ -124,9 +119,6 @@ class CliMainTest < CliTestCase
test "deploy error when locking" do test "deploy error when locking" do
Thread.report_on_exception = false Thread.report_on_exception = false
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
Dir.stubs(:chdir)
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args == [ :mkdir, "-p", ".kamal" ] } .with { |*args| args == [ :mkdir, "-p", ".kamal" ] }
@@ -137,14 +129,6 @@ 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
@@ -170,6 +154,7 @@ class CliMainTest < CliTestCase
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options)
@@ -179,8 +164,10 @@ class CliMainTest < CliTestCase
end end
end end
test "deploy with missing secrets" do test "deploy without healthcheck if primary host doesn't have proxy" do
invoke_options = { "config_file" => "test/fixtures/deploy_with_secrets.yml", "version" => "999", "skip_hooks" => false } invoke_options = { "config_file" => "test/fixtures/deploy_workers_only.yml", "version" => "999", "skip_hooks" => false }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options).never
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options)
@@ -189,13 +176,28 @@ class CliMainTest < CliTestCase
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options)
run_command("deploy", config_file: "deploy_workers_only")
end
test "deploy with missing secrets" do
invoke_options = { "config_file" => "test/fixtures/deploy_with_secrets.yml", "version" => "999", "skip_hooks" => false }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options)
run_command("deploy", config_file: "deploy_with_secrets") run_command("deploy", config_file: "deploy_with_secrets")
end end
test "redeploy" do test "redeploy" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, "verbose" => true } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options)
@@ -203,12 +205,13 @@ class CliMainTest < CliTestCase
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "redeploy" } hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "redeploy" }
run_command("redeploy", "--verbose").tap do |output| run_command("redeploy").tap do |output|
assert_hook_ran "pre-connect", output, **hook_variables assert_hook_ran "pre-connect", output, **hook_variables
assert_match /Build and push app image/, output assert_match /Build and push app image/, output
assert_hook_ran "pre-deploy", output, **hook_variables assert_hook_ran "pre-deploy", output, **hook_variables
assert_match /Running the pre-deploy hook.../, output assert_match /Running the pre-deploy hook.../, output
assert_hook_ran "post-deploy", output, **hook_variables, runtime: true assert_match /Ensure app can pass healthcheck/, output
assert_hook_ran "post-deploy", output, **hook_variables, runtime: "0"
end end
end end
@@ -216,11 +219,13 @@ class CliMainTest < CliTestCase
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:pull", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:pull", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options)
run_command("redeploy", "--skip_push").tap do |output| run_command("redeploy", "--skip_push").tap do |output|
assert_match /Pull app image/, output assert_match /Pull app image/, output
assert_match /Ensure app can pass healthcheck/, output
end end
end end
@@ -236,6 +241,7 @@ class CliMainTest < CliTestCase
end end
test "rollback good version" do test "rollback good version" do
Object.any_instance.stubs(:sleep)
[ "web", "workers" ].each do |role| [ "web", "workers" ].each do |role|
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) 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) .with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet", raise_on_non_zero_exit: false)
@@ -244,38 +250,34 @@ class CliMainTest < CliTestCase
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet") .with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet")
.returns("version-to-rollback\n").at_least_once .returns("version-to-rollback\n").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --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=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) .with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=#{role}", "--filter", "status=running", "--filter", "status=restarting", "--latest", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-#{role}-}; done", raise_on_non_zero_exit: false)
.returns("version-to-rollback\n").at_least_once .returns("version-to-rollback\n").at_least_once
end end
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
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 123, service_version: "app@123", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "rollback" } hook_variables = { version: 123, service_version: "app@123", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "rollback" }
run_command("rollback", "--verbose", "123", config_file: "deploy_with_accessories").tap do |output| run_command("rollback", "123", config_file: "deploy_with_accessories").tap do |output|
assert_hook_ran "pre-deploy", output, **hook_variables assert_hook_ran "pre-deploy", output, **hook_variables
assert_match "docker tag dhh/app:123 dhh/app:latest", 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 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_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, **hook_variables, runtime: true assert_hook_ran "post-deploy", output, **hook_variables, runtime: "0"
end end
end end
test "rollback without old version" do test "rollback without old version" do
Kamal::Cli::Main.any_instance.stubs(:container_available?).returns(true) Kamal::Cli::Main.any_instance.stubs(:container_available?).returns(true)
Kamal::Cli::Healthcheck::Poller.stubs(:sleep)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", raise_on_non_zero_exit: false) .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", raise_on_non_zero_exit: false)
.returns("").at_least_once .returns("").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --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=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--filter", "status=running", "--filter", "status=restarting", "--latest", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("").at_least_once .returns("").at_least_once
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("127.1.0.4:80").at_least_once
run_command("rollback", "123").tap do |output| run_command("rollback", "123").tap do |output|
assert_match "docker run --detach --restart unless-stopped --name app-web-123", output assert_match "docker run --detach --restart unless-stopped --name app-web-123", output
@@ -412,7 +414,6 @@ class CliMainTest < CliTestCase
end end
test "envify" do test "envify" do
Pathname.any_instance.expects(:exist?).returns(true).times(3)
File.expects(:read).with(".env.erb").returns("HELLO=<%= 'world' %>") File.expects(:read).with(".env.erb").returns("HELLO=<%= 'world' %>")
File.expects(:write).with(".env", "HELLO=world", perm: 0600) File.expects(:write).with(".env", "HELLO=world", perm: 0600)
@@ -427,7 +428,6 @@ class CliMainTest < CliTestCase
<% end -%> <% end -%>
EOF EOF
Pathname.any_instance.expects(:exist?).returns(true).times(3)
File.expects(:read).with(".env.erb").returns(file.strip) File.expects(:read).with(".env.erb").returns(file.strip)
File.expects(:write).with(".env", "HELLO=world\nKEY=value\n", perm: 0600) File.expects(:write).with(".env", "HELLO=world\nKEY=value\n", perm: 0600)
@@ -435,7 +435,6 @@ class CliMainTest < CliTestCase
end end
test "envify with destination" do test "envify with destination" do
Pathname.any_instance.expects(:exist?).returns(true).times(4)
File.expects(:read).with(".env.world.erb").returns("HELLO=<%= 'world' %>") File.expects(:read).with(".env.world.erb").returns("HELLO=<%= 'world' %>")
File.expects(:write).with(".env.world", "HELLO=world", perm: 0600) File.expects(:write).with(".env.world", "HELLO=world", perm: 0600)
@@ -443,7 +442,6 @@ class CliMainTest < CliTestCase
end end
test "envify with skip_push" do test "envify with skip_push" do
Pathname.any_instance.expects(:exist?).returns(true).times(1)
File.expects(:read).with(".env.erb").returns("HELLO=<%= 'world' %>") File.expects(:read).with(".env.erb").returns("HELLO=<%= 'world' %>")
File.expects(:write).with(".env", "HELLO=world", perm: 0600) File.expects(:write).with(".env", "HELLO=world", perm: 0600)
@@ -453,9 +451,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 kamal-proxy/, output assert_match /docker container stop mproxy/, output
assert_match /docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy/, output assert_match /docker container prune --force --filter label=org.opencontainers.image.title=mproxy/, output
assert_match /docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy/, output assert_match /docker image prune --all --force --filter label=org.opencontainers.image.title=mproxy/, 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 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 run --name mproxy --detach --restart unless-stopped --network kamal --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{Kamal::Commands::Proxy::DEFAULT_IMAGE}", output
end end
end end
@@ -12,27 +12,29 @@ 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 kamal-proxy", output assert_match "docker container stop mproxy", output
assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=mproxy", 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 run --name mproxy --detach --restart unless-stopped --network kamal --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{Kamal::Commands::Proxy::DEFAULT_IMAGE}", output
end end
end end
test "reboot --rolling" do test "reboot --rolling" do
Object.any_instance.stubs(:sleep)
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=kamal-proxy on 1.1.1.1", output assert_match "Running docker container prune --force --filter label=org.opencontainers.image.title=mproxy 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 kamal-proxy", output assert_match "docker container start mproxy", 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 kamal-proxy", output assert_match "docker container stop mproxy", output
end end
end end
@@ -45,13 +47,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=^kamal-proxy$", output assert_match "docker ps --filter name=^mproxy$", 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, "kamal-proxy", " --tail 100", "--timestamps", "2>&1") .with(:docker, :logs, "mproxy", " --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 +64,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 kamal-proxy --timestamps --tail 10 --follow 2>&1'") .with("ssh -t root@1.1.1.1 -p 22 'docker logs mproxy --timestamps --tail 10 --follow 2>&1'")
assert_match "docker logs kamal-proxy --timestamps --tail 10 --follow", run_command("logs", "--follow") assert_match "docker logs mproxy --timestamps --tail 10 --follow", run_command("logs", "--follow")
end end
test "remove" do test "remove" do
@@ -77,35 +79,13 @@ 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=kamal-proxy", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=mproxy", 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=kamal-proxy", output assert_match "docker image prune --all --force --filter label=org.opencontainers.image.title=mproxy", 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

@@ -18,10 +18,12 @@ class CliPruneTest < CliTestCase
test "containers" do test "containers" do
run_command("containers").tap do |output| run_command("containers").tap do |output|
assert_match /docker ps -q -a --filter label=service=app --filter status=created --filter status=exited --filter status=dead | tail -n +6 | while read container_id; do docker rm $container_id; done on 1.1.1.\d/, output assert_match /docker ps -q -a --filter label=service=app --filter status=created --filter status=exited --filter status=dead | tail -n +6 | while read container_id; do docker rm $container_id; done on 1.1.1.\d/, output
assert_match /docker container prune --force --filter label=service=healthcheck-app on 1.1.1.\d/, output
end end
run_command("containers", "--retain", "10").tap do |output| run_command("containers", "--retain", "10").tap do |output|
assert_match /docker ps -q -a --filter label=service=app --filter status=created --filter status=exited --filter status=dead | tail -n +11 | while read container_id; do docker rm $container_id; done on 1.1.1.\d/, output assert_match /docker ps -q -a --filter label=service=app --filter status=created --filter status=exited --filter status=dead | tail -n +11 | while read container_id; do docker rm $container_id; done on 1.1.1.\d/, output
assert_match /docker container prune --force --filter label=service=healthcheck-app on 1.1.1.\d/, output
end end
assert_raises(RuntimeError, "retain must be at least 1") do assert_raises(RuntimeError, "retain must be at least 1") do

View File

@@ -1,33 +1,19 @@
require_relative "cli_test_case" require_relative "cli_test_case"
class CliServerTest < CliTestCase class CliServerTest < CliTestCase
test "running a command with exec" do
SSHKit::Backend::Abstract.any_instance.stubs(:capture)
.with("date", verbosity: 1)
.returns("Today")
hosts = "1.1.1.1".."1.1.1.4"
run_command("exec", "date").tap do |output|
hosts.map do |host|
assert_match "Running 'date' on #{hosts.to_a.join(', ')}...", output
assert_match "App Host: #{host}\nToday", output
end
end
end
test "bootstrap already installed" do test "bootstrap already installed" do
stub_setup
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(true).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(true).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, "-p", ".kamal").returns("").at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, "-p", ".kamal").returns("").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, :network, :create, :kamal).returns("").at_least_once
assert_equal "Acquiring the deploy lock...\nReleasing the deploy lock...", run_command("bootstrap") assert_equal "", run_command("bootstrap")
end end
test "bootstrap install as non-root user" do test "bootstrap install as non-root user" do
stub_setup
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(false).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(false).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ "${EUID:-$(id -u)}" -eq 0 ] || command -v sudo >/dev/null || command -v su >/dev/null', raise_on_non_zero_exit: false).returns(false).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ "${EUID:-$(id -u)}" -eq 0 ] || command -v sudo >/dev/null || command -v su >/dev/null', raise_on_non_zero_exit: false).returns(false).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, "-p", ".kamal").returns("").at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, "-p", ".kamal").returns("").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, :network, :create, :kamal).returns("").at_least_once
assert_raise RuntimeError, "Docker is not installed on 1.1.1.1, 1.1.1.3, 1.1.1.4, 1.1.1.2 and can't be automatically installed without having root access and the `curl` command available. Install Docker manually: https://docs.docker.com/engine/install/" do assert_raise RuntimeError, "Docker is not installed on 1.1.1.1, 1.1.1.3, 1.1.1.4, 1.1.1.2 and can't be automatically installed without having root access and the `curl` command available. Install Docker manually: https://docs.docker.com/engine/install/" do
run_command("bootstrap") run_command("bootstrap")
@@ -35,13 +21,12 @@ class CliServerTest < CliTestCase
end end
test "bootstrap install as root user" do test "bootstrap install as root user" do
stub_setup
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(false).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(false).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ "${EUID:-$(id -u)}" -eq 0 ] || command -v sudo >/dev/null || command -v su >/dev/null', raise_on_non_zero_exit: false).returns(true).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ "${EUID:-$(id -u)}" -eq 0 ] || command -v sudo >/dev/null || command -v su >/dev/null', raise_on_non_zero_exit: false).returns(true).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:sh, "-c", "'curl -fsSL https://get.docker.com || wget -O - https://get.docker.com || echo \"exit 1\"'", "|", :sh).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:sh, "-c", "'curl -fsSL https://get.docker.com || wget -O - https://get.docker.com || echo \"exit 1\"'", "|", :sh).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, "-p", ".kamal").returns("").at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, "-p", ".kamal").returns("").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, :network, :create, :kamal).returns("").at_least_once
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(".kamal/hooks/pre-connect", anything).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(".kamal/hooks/docker-setup", anything).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(".kamal/hooks/docker-setup", anything).at_least_once
run_command("bootstrap").tap do |output| run_command("bootstrap").tap do |output|

View File

@@ -24,9 +24,6 @@ class CommanderTest < ActiveSupport::TestCase
@kamal.specific_hosts = [ "*" ] @kamal.specific_hosts = [ "*" ]
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.hosts assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.hosts
@kamal.specific_hosts = [ "1.1.1.[12]" ]
assert_equal [ "1.1.1.1", "1.1.1.2" ], @kamal.hosts
exception = assert_raises(ArgumentError) do exception = assert_raises(ArgumentError) do
@kamal.specific_hosts = [ "*miss" ] @kamal.specific_hosts = [ "*miss" ]
end end
@@ -60,9 +57,6 @@ class CommanderTest < ActiveSupport::TestCase
@kamal.specific_roles = [ "*" ] @kamal.specific_roles = [ "*" ]
assert_equal [ "web", "workers" ], @kamal.roles.map(&:name) assert_equal [ "web", "workers" ], @kamal.roles.map(&:name)
@kamal.specific_roles = [ "w{eb,orkers}" ]
assert_equal [ "web", "workers" ], @kamal.roles.map(&:name)
exception = assert_raises(ArgumentError) do exception = assert_raises(ArgumentError) do
@kamal.specific_roles = [ "*miss" ] @kamal.specific_roles = [ "*miss" ]
end end
@@ -99,11 +93,6 @@ class CommanderTest < ActiveSupport::TestCase
assert_equal [ "workers" ], @kamal.roles_on("1.1.1.3").map(&:name) assert_equal [ "workers" ], @kamal.roles_on("1.1.1.3").map(&:name)
end end
test "roles_on web comes first" do
configure_with(:deploy_with_two_roles_one_host)
assert_equal [ "web", "workers" ], @kamal.roles_on("1.1.1.1").map(&:name)
end
test "default group strategy" do test "default group strategy" do
assert_empty @kamal.boot_strategy assert_empty @kamal.boot_strategy
end end
@@ -130,24 +119,9 @@ class CommanderTest < ActiveSupport::TestCase
configure_with(:deploy_primary_web_role_override) configure_with(:deploy_primary_web_role_override)
@kamal.specific_roles = [ "web_*" ] @kamal.specific_roles = [ "web_*" ]
assert_equal [ "web_tokyo", "web_chicago" ], @kamal.roles.map(&:name) assert_equal [ "web_chicago", "web_tokyo" ], @kamal.roles.map(&:name)
assert_equal "web_tokyo", @kamal.primary_role.name assert_equal "web_tokyo", @kamal.primary_role.name
assert_equal "1.1.1.3", @kamal.primary_host assert_equal "1.1.1.3", @kamal.primary_host
assert_equal [ "1.1.1.3", "1.1.1.4", "1.1.1.1", "1.1.1.2" ], @kamal.hosts
end
test "proxy hosts should observe filtered roles" do
configure_with(:deploy_with_aliases)
@kamal.specific_roles = [ "web_tokyo" ]
assert_equal [ "1.1.1.3", "1.1.1.4" ], @kamal.proxy_hosts
end
test "proxy hosts should observe filtered hosts" do
configure_with(:deploy_with_aliases)
@kamal.specific_hosts = [ "1.1.1.4" ]
assert_equal [ "1.1.1.4" ], @kamal.proxy_hosts
end end
private private

View File

@@ -14,13 +14,13 @@ class CommandsAppTest < ActiveSupport::TestCase
test "run" do test "run" do
assert_equal \ assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", "docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
new_command.run.join(" ") new_command.run.join(" ")
end end
test "run with hostname" do test "run with hostname" do
assert_equal \ assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 --hostname myhost -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", "docker run --detach --restart unless-stopped --name app-web-999 --network kamal --hostname myhost -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
new_command.run(hostname: "myhost").join(" ") new_command.run(hostname: "myhost").join(" ")
end end
@@ -28,22 +28,46 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:volumes] = [ "/local/path:/container/path" ] @config[:volumes] = [ "/local/path:/container/path" ]
assert_equal \ assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --log-opt max-size=\"10m\" --volume /local/path:/container/path --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", "docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" --log-opt max-size=\"10m\" --volume /local/path:/container/path --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
new_command.run.join(" ")
end
test "run with custom healthcheck path" do
@config[:healthcheck] = { "path" => "/healthz" }
assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"curl -f http://localhost:3000/healthz || exit 1\" --health-interval \"1s\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
new_command.run.join(" ")
end
test "run with custom healthcheck command" do
@config[:healthcheck] = { "cmd" => "/bin/up" }
assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"/bin/up\" --health-interval \"1s\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
new_command.run.join(" ")
end
test "run with role-specific healthcheck options" do
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "healthcheck" => { "cmd" => "/bin/healthy" } } }
assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"/bin/healthy\" --health-interval \"1s\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
new_command.run.join(" ") new_command.run.join(" ")
end end
test "run with custom options" do test "run with custom options" do
@config[:servers] = { "web" => [ "1.1.1.1" ], "jobs" => { "hosts" => [ "1.1.1.2" ], "cmd" => "bin/jobs", "options" => { "mount" => "somewhere", "cap-add" => true } } } @config[:servers] = { "web" => [ "1.1.1.1" ], "jobs" => { "hosts" => [ "1.1.1.2" ], "cmd" => "bin/jobs", "options" => { "mount" => "somewhere", "cap-add" => true } } }
assert_equal \ assert_equal \
"docker run --detach --restart unless-stopped --name app-jobs-999 -e KAMAL_CONTAINER_NAME=\"app-jobs-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-jobs.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"jobs\" --label destination --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs", "docker run --detach --restart unless-stopped --name app-jobs-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-jobs-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-jobs.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"jobs\" --label destination --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs",
new_command(role: "jobs", host: "1.1.1.2").run.join(" ") new_command(role: "jobs").run.join(" ")
end end
test "run with logging config" do test "run with logging config" do
@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 --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", "docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -52,16 +76,7 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "logging" => { "driver" => "local", "options" => { "max-size" => "100m" } } } } @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "logging" => { "driver" => "local", "options" => { "max-size" => "100m" } } } }
assert_equal \ assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", "docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
new_command.run.join(" ")
end
test "run with tags" do
@config[:servers] = [ { "1.1.1.1" => "tag1" } ]
@config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } }
assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -80,14 +95,14 @@ class CommandsAppTest < ActiveSupport::TestCase
test "stop" do test "stop" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker stop",
new_command.stop.join(" ") new_command.stop.join(" ")
end end
test "stop with custom stop wait time" do test "stop with custom stop wait time" do
@config[:stop_wait_time] = 30 @config[:stop_wait_time] = 30
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop -t 30", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker stop -t 30",
new_command.stop.join(" ") new_command.stop.join(" ")
end end
@@ -113,45 +128,45 @@ class CommandsAppTest < ActiveSupport::TestCase
test "logs" do test "logs" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs 2>&1",
new_command.logs.join(" ") new_command.logs.join(" ")
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --since 5m 2>&1", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --since 5m 2>&1",
new_command.logs(since: "5m").join(" ") new_command.logs(since: "5m").join(" ")
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --tail 100 2>&1", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --tail 100 2>&1",
new_command.logs(lines: "100").join(" ") new_command.logs(lines: "100").join(" ")
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --since 5m --tail 100 2>&1", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --since 5m --tail 100 2>&1",
new_command.logs(since: "5m", lines: "100").join(" ") new_command.logs(since: "5m", lines: "100").join(" ")
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1 | grep 'my-id'", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs 2>&1 | grep 'my-id'",
new_command.logs(grep: "my-id").join(" ") new_command.logs(grep: "my-id").join(" ")
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --since 5m 2>&1 | grep 'my-id'", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --since 5m 2>&1 | grep 'my-id'",
new_command.logs(since: "5m", grep: "my-id").join(" ") new_command.logs(since: "5m", grep: "my-id").join(" ")
end end
test "follow logs" do test "follow logs" do
assert_equal \ assert_match \
"ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1'", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --follow 2>&1",
new_command.follow_logs(host: "app-1") new_command.follow_logs(host: "app-1")
assert_equal \ assert_match \
"ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"Completed\"'", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --follow 2>&1 | grep \"Completed\"",
new_command.follow_logs(host: "app-1", grep: "Completed") new_command.follow_logs(host: "app-1", grep: "Completed")
assert_equal \ assert_match \
"ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1'", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --tail 123 --follow 2>&1",
new_command.follow_logs(host: "app-1", lines: 123) new_command.follow_logs(host: "app-1", lines: 123)
assert_equal \ assert_match \
"ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1 | grep \"Completed\"'", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --tail 123 --follow 2>&1 | grep \"Completed\"",
new_command.follow_logs(host: "app-1", lines: 123, grep: "Completed") new_command.follow_logs(host: "app-1", lines: 123, grep: "Completed")
end end
@@ -159,65 +174,36 @@ class CommandsAppTest < ActiveSupport::TestCase
test "execute in new container" do test "execute in new container" do
assert_equal \ assert_equal \
"docker run --rm --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails db:setup", "docker run --rm --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails db:setup",
new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") new_command.execute_in_new_container("bin/rails", "db:setup").join(" ")
end
test "execute in new container with env" do
assert_equal \
"docker run --rm --env-file .kamal/env/roles/app-web.env --env foo=\"bar\" dhh/app:999 bin/rails db:setup",
new_command.execute_in_new_container("bin/rails", "db:setup", env: { "foo" => "bar" }).join(" ")
end
test "execute in new container with tags" do
@config[:servers] = [ { "1.1.1.1" => "tag1" } ]
@config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } }
assert_equal \
"docker run --rm --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" dhh/app:999 bin/rails db:setup",
new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ")
end end
test "execute in new container with custom options" do test "execute in new container with custom options" do
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } }
assert_equal \ assert_equal \
"docker run --rm --env-file .kamal/env/roles/app-web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails db:setup", "docker run --rm --env-file .kamal/env/roles/app-web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails db:setup",
new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") new_command.execute_in_new_container("bin/rails", "db:setup").join(" ")
end end
test "execute in existing container" do test "execute in existing container" do
assert_equal \ assert_equal \
"docker exec app-web-999 bin/rails db:setup", "docker exec app-web-999 bin/rails db:setup",
new_command.execute_in_existing_container("bin/rails", "db:setup", env: {}).join(" ") new_command.execute_in_existing_container("bin/rails", "db:setup").join(" ")
end
test "execute in existing container with env" do
assert_equal \
"docker exec --env foo=\"bar\" app-web-999 bin/rails db:setup",
new_command.execute_in_existing_container("bin/rails", "db:setup", env: { "foo" => "bar" }).join(" ")
end end
test "execute in new container over ssh" do test "execute in new container over ssh" do
assert_match %r{docker run -it --rm --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails c}, assert_match %r{docker run -it --rm --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails c},
new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) new_command.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1")
end
test "execute in new container over ssh with tags" do
@config[:servers] = [ { "1.1.1.1" => "tag1" } ]
@config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } }
assert_equal "ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" dhh/app:999 bin/rails c'",
new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {})
end end
test "execute in new container with custom options over ssh" do test "execute in new container with custom options over ssh" do
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } }
assert_match %r{docker run -it --rm --env-file .kamal/env/roles/app-web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails c}, assert_match %r{docker run -it --rm --env-file .kamal/env/roles/app-web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails c},
new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) new_command.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1")
end end
test "execute in existing container over ssh" do test "execute in existing container over ssh" do
assert_match %r{docker exec -it app-web-999 bin/rails c}, assert_match %r{docker exec -it app-web-999 bin/rails c},
new_command.execute_in_existing_container_over_ssh("bin/rails", "c", env: {}) new_command.execute_in_existing_container_over_ssh("bin/rails", "c", host: "app-1")
end end
test "run over ssh" do test "run over ssh" do
@@ -256,14 +242,14 @@ class CommandsAppTest < ActiveSupport::TestCase
test "current_running_container_id" do test "current_running_container_id" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest",
new_command.current_running_container_id.join(" ") new_command.current_running_container_id.join(" ")
end end
test "current_running_container_id with destination" do test "current_running_container_id with destination" do
@destination = "staging" @destination = "staging"
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination=staging --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest-staging --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination=staging --filter label=role=web --filter status=running --filter status=restarting' | head -1", "docker ps --quiet --filter label=service=app --filter label=destination=staging --filter label=role=web --filter status=running --filter status=restarting --latest",
new_command.current_running_container_id.join(" ") new_command.current_running_container_id.join(" ")
end end
@@ -275,7 +261,7 @@ class CommandsAppTest < ActiveSupport::TestCase
test "current_running_version" do test "current_running_version" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --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=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", "docker ps --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done",
new_command.current_running_version.join(" ") new_command.current_running_version.join(" ")
end end
@@ -353,17 +339,10 @@ class CommandsAppTest < ActiveSupport::TestCase
new_command.remove_images.join(" ") new_command.remove_images.join(" ")
end end
test "tag_latest_image" do test "tag_current_image_as_latest" do
assert_equal \ assert_equal \
"docker tag dhh/app:999 dhh/app:latest", "docker tag dhh/app:999 dhh/app:latest",
new_command.tag_latest_image.join(" ") new_command.tag_current_image_as_latest.join(" ")
end
test "tag_latest_image with destination" do
@destination = "staging"
assert_equal \
"docker tag dhh/app:999 dhh/app:latest-staging",
new_command.tag_latest_image.join(" ")
end end
test "make_env_directory" do test "make_env_directory" do
@@ -378,7 +357,7 @@ class CommandsAppTest < ActiveSupport::TestCase
assert_equal [ assert_equal [
:mkdir, "-p", ".kamal/assets/extracted/app-web-999", "&&", :mkdir, "-p", ".kamal/assets/extracted/app-web-999", "&&",
:docker, :stop, "-t 1", "app-web-assets", "2> /dev/null", "|| true", "&&", :docker, :stop, "-t 1", "app-web-assets", "2> /dev/null", "|| true", "&&",
:docker, :run, "--name", "app-web-assets", "--detach", "--rm", "dhh/app:999", "sleep 1000000", "&&", :docker, :run, "--name", "app-web-assets", "--detach", "--rm", "dhh/app:latest", "sleep 1000000", "&&",
:docker, :cp, "-L", "app-web-assets:/public/assets/.", ".kamal/assets/extracted/app-web-999", "&&", :docker, :cp, "-L", "app-web-assets:/public/assets/.", ".kamal/assets/extracted/app-web-999", "&&",
:docker, :stop, "-t 1", "app-web-assets" :docker, :stop, "-t 1", "app-web-assets"
], new_command(asset_path: "/public/assets").extract_assets ], new_command(asset_path: "/public/assets").extract_assets
@@ -406,8 +385,8 @@ class CommandsAppTest < ActiveSupport::TestCase
end end
private private
def new_command(role: "web", host: "1.1.1.1", **additional_config) def new_command(role: "web", **additional_config)
config = Kamal::Configuration.new(@config.merge(additional_config), destination: @destination, version: "999") config = Kamal::Configuration.new(@config.merge(additional_config), destination: @destination, version: "999")
Kamal::Commands::App.new(config, role: config.role(role), host: host) Kamal::Commands::App.new(config, role: config.role(role))
end end
end end

View File

@@ -9,7 +9,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "cache" => { "type" => "gha" } }) builder = new_builder_command(builder: { "cache" => { "type" => "gha" } })
assert_equal "multiarch", builder.name assert_equal "multiarch", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-app-multiarch -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", "git archive --format=tar HEAD | docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-app-multiarch -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile -",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -17,7 +17,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "multiarch" => false }) builder = new_builder_command(builder: { "multiarch" => false })
assert_equal "native", builder.name assert_equal "native", builder.name
assert_equal \ assert_equal \
"docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile . && docker push dhh/app:123 && docker push dhh/app:latest", "git archive --format=tar HEAD | docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile - && docker push dhh/app:123 && docker push dhh/app:latest",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -25,7 +25,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "multiarch" => false, "cache" => { "type" => "gha" } }) builder = new_builder_command(builder: { "multiarch" => false, "cache" => { "type" => "gha" } })
assert_equal "native/cached", builder.name assert_equal "native/cached", builder.name
assert_equal \ assert_equal \
"docker buildx build --push -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", "git archive --format=tar HEAD | docker buildx build --push -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile -",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -33,7 +33,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "local" => {}, "remote" => {}, "cache" => { "type" => "gha" } }) builder = new_builder_command(builder: { "local" => {}, "remote" => {}, "cache" => { "type" => "gha" } })
assert_equal "multiarch/remote", builder.name assert_equal "multiarch/remote", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-app-multiarch-remote -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", "git archive --format=tar HEAD | docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-app-multiarch-remote -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile -",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -41,7 +41,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "local" => { "arch" => "amd64" } }) builder = new_builder_command(builder: { "local" => { "arch" => "amd64" } })
assert_equal "multiarch", builder.name assert_equal "multiarch", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .", "git archive --format=tar HEAD | docker buildx build --push --platform linux/amd64 --builder kamal-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile -",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -49,7 +49,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "remote" => { "arch" => "amd64" }, "cache" => { "type" => "gha" } }) builder = new_builder_command(builder: { "remote" => { "arch" => "amd64" }, "cache" => { "type" => "gha" } })
assert_equal "native/remote", builder.name assert_equal "native/remote", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-app-native-remote -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", "git archive --format=tar HEAD | docker buildx build --push --platform linux/amd64 --builder kamal-app-native-remote -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile -",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -83,13 +83,6 @@ class CommandsBuilderTest < ActiveSupport::TestCase
end end
end end
test "build target" do
builder = new_builder_command(builder: { "target" => "prod" })
assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --target prod",
builder.target.build_options.join(" ")
end
test "build context" do test "build context" do
builder = new_builder_command(builder: { "context" => ".." }) builder = new_builder_command(builder: { "context" => ".." })
assert_equal \ assert_equal \
@@ -100,21 +93,21 @@ class CommandsBuilderTest < ActiveSupport::TestCase
test "native push with build args" do test "native push with build args" do
builder = new_builder_command(builder: { "multiarch" => false, "args" => { "a" => 1, "b" => 2 } }) builder = new_builder_command(builder: { "multiarch" => false, "args" => { "a" => 1, "b" => 2 } })
assert_equal \ assert_equal \
"docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile . && docker push dhh/app:123 && docker push dhh/app:latest", "git archive --format=tar HEAD | docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile - && docker push dhh/app:123 && docker push dhh/app:latest",
builder.push.join(" ") builder.push.join(" ")
end end
test "multiarch push with build args" do test "multiarch push with build args" do
builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } }) builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } })
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile .", "git archive --format=tar HEAD | docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile -",
builder.push.join(" ") builder.push.join(" ")
end end
test "native push with build secrets" do test "native push with build secrets" do
builder = new_builder_command(builder: { "multiarch" => false, "secrets" => [ "a", "b" ] }) builder = new_builder_command(builder: { "multiarch" => false, "secrets" => [ "a", "b" ] })
assert_equal \ assert_equal \
"docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" --file Dockerfile . && docker push dhh/app:123 && docker push dhh/app:latest", "git archive --format=tar HEAD | docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" --file Dockerfile - && docker push dhh/app:123 && docker push dhh/app:latest",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -162,8 +155,4 @@ class CommandsBuilderTest < ActiveSupport::TestCase
def new_builder_command(additional_config = {}) def new_builder_command(additional_config = {})
Kamal::Commands::Builder.new(Kamal::Configuration.new(@config.merge(additional_config), version: "123")) Kamal::Commands::Builder.new(Kamal::Configuration.new(@config.merge(additional_config), version: "123"))
end end
def build_directory
"#{Dir.tmpdir}/kamal-clones/app/kamal/"
end
end end

View File

@@ -0,0 +1,113 @@
require "test_helper"
class CommandsHealthcheckTest < ActiveSupport::TestCase
setup do
@config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ]
}
end
test "run" do
assert_equal \
"docker run --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app -e KAMAL_CONTAINER_NAME=\"healthcheck-app\" --env-file .kamal/env/roles/app-web.env --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" dhh/app:123",
new_command.run.join(" ")
end
test "run with custom port" do
@config[:healthcheck] = { "port" => 3001 }
assert_equal \
"docker run --detach --name healthcheck-app-123 --publish 3999:3001 --label service=healthcheck-app -e KAMAL_CONTAINER_NAME=\"healthcheck-app\" --env-file .kamal/env/roles/app-web.env --health-cmd \"curl -f http://localhost:3001/up || exit 1\" --health-interval \"1s\" dhh/app:123",
new_command.run.join(" ")
end
test "run with destination" do
@destination = "staging"
assert_equal \
"docker run --detach --name healthcheck-app-staging-123 --publish 3999:3000 --label service=healthcheck-app-staging -e KAMAL_CONTAINER_NAME=\"healthcheck-app-staging\" --env-file .kamal/env/roles/app-web-staging.env --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" dhh/app:123",
new_command.run.join(" ")
end
test "run with custom healthcheck" do
@config[:healthcheck] = { "cmd" => "/bin/up" }
assert_equal \
"docker run --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app -e KAMAL_CONTAINER_NAME=\"healthcheck-app\" --env-file .kamal/env/roles/app-web.env --health-cmd \"/bin/up\" --health-interval \"1s\" dhh/app:123",
new_command.run.join(" ")
end
test "run with custom options" do
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere" } } }
@config[:healthcheck] = { "exposed_port" => 4999 }
assert_equal \
"docker run --detach --name healthcheck-app-123 --publish 4999:3000 --label service=healthcheck-app -e KAMAL_CONTAINER_NAME=\"healthcheck-app\" --env-file .kamal/env/roles/app-web.env --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" --mount \"somewhere\" dhh/app:123",
new_command.run.join(" ")
end
test "status" do
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'",
new_command.status.join(" ")
end
test "container_health_log" do
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker inspect --format '{{json .State.Health}}'",
new_command.container_health_log.join(" ")
end
test "stop" do
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker stop",
new_command.stop.join(" ")
end
test "stop with destination" do
@destination = "staging"
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-staging-123$ --quiet | xargs docker stop",
new_command.stop.join(" ")
end
test "remove" do
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker container rm",
new_command.remove.join(" ")
end
test "remove with destination" do
@destination = "staging"
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-staging-123$ --quiet | xargs docker container rm",
new_command.remove.join(" ")
end
test "logs" do
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker logs --tail 50 2>&1",
new_command.logs.join(" ")
end
test "logs with custom lines number" do
@config[:healthcheck] = { "log_lines" => 150 }
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker logs --tail 150 2>&1",
new_command.logs.join(" ")
end
test "logs with destination" do
@destination = "staging"
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-staging-123$ --quiet | xargs docker logs --tail 50 2>&1",
new_command.logs.join(" ")
end
private
def new_command
Kamal::Commands::Healthcheck.new(Kamal::Configuration.new(@config, destination: @destination, version: "123"))
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 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}", "docker run --name mproxy --detach --restart unless-stopped --network kamal --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{Kamal::Commands::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 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}", "docker run --name mproxy --detach --restart unless-stopped --network kamal --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{Kamal::Commands::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 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}", "docker run --name mproxy --detach --restart unless-stopped --network kamal --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{Kamal::Commands::Proxy::DEFAULT_IMAGE}",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -37,88 +37,76 @@ 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 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}", "docker run --name mproxy --detach --restart unless-stopped --network kamal --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" #{Kamal::Commands::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 kamal-proxy", "docker container start mproxy",
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 kamal-proxy", "docker container stop mproxy",
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=^kamal-proxy$", "docker ps --filter name=^mproxy$",
new_command.info.join(" ") new_command.info.join(" ")
end end
test "proxy logs" do test "proxy logs" do
assert_equal \ assert_equal \
"docker logs kamal-proxy --timestamps 2>&1", "docker logs mproxy --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 kamal-proxy --since 2h --timestamps 2>&1", "docker logs mproxy --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 kamal-proxy --tail 10 --timestamps 2>&1", "docker logs mproxy --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 kamal-proxy --timestamps 2>&1 | grep 'hello!'", "docker logs mproxy --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=kamal-proxy", "docker container prune --force --filter label=org.opencontainers.image.title=mproxy",
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=kamal-proxy", "docker image prune --all --force --filter label=org.opencontainers.image.title=mproxy",
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 kamal-proxy --timestamps --tail 10 --follow 2>&1'", "ssh -t root@1.1.1.1 -p 22 'docker logs mproxy --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 kamal-proxy --timestamps --tail 10 --follow 2>&1 | grep \"hello!\"'", "ssh -t root@1.1.1.1 -p 22 'docker logs mproxy --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
assert_equal \
"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(" ")
end
test "remove" do
assert_equal \
"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(" ")
end
private private
def new_command def new_command
Kamal::Commands::Proxy.new(Kamal::Configuration.new(@config, version: "123")) Kamal::Commands::Proxy.new(Kamal::Configuration.new(@config, version: "123"))

View File

@@ -29,6 +29,12 @@ class CommandsPruneTest < ActiveSupport::TestCase
new_command.app_containers(retain: 3).join(" ") new_command.app_containers(retain: 3).join(" ")
end end
test "healthcheck containers" do
assert_equal \
"docker container prune --force --filter label=service=healthcheck-app",
new_command.healthcheck_containers.join(" ")
end
private private
def new_command def new_command
Kamal::Commands::Prune.new(Kamal::Configuration.new(@config, version: "123")) Kamal::Commands::Prune.new(Kamal::Configuration.new(@config, version: "123"))

View File

@@ -107,10 +107,6 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
assert_equal "Specify one of `host`, `hosts` or `roles` for accessory `mysql`", exception.message assert_equal "Specify one of `host`, `hosts` or `roles` for accessory `mysql`", exception.message
end end
test "all hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4", "1.1.1.5", "1.1.1.6", "1.1.1.7" ], @config.all_hosts
end
test "label args" do test "label args" do
assert_equal [ "--label", "service=\"app-mysql\"" ], @config.accessory(:mysql).label_args assert_equal [ "--label", "service=\"app-mysql\"" ], @config.accessory(:mysql).label_args
assert_equal [ "--label", "service=\"app-redis\"", "--label", "cache=\"true\"" ], @config.accessory(:redis).label_args assert_equal [ "--label", "service=\"app-redis\"", "--label", "cache=\"true\"" ], @config.accessory(:redis).label_args

View File

@@ -140,7 +140,7 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase
end end
test "context" do test "context" do
assert_equal ".", @config.builder.context assert_equal "-", @config.builder.context
end end
test "setting context" do test "setting context" do

View File

@@ -1,112 +0,0 @@
require "test_helper"
class ConfigurationEnvTagsTest < ActiveSupport::TestCase
setup do
@deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ { "1.1.1.1" => "odd" }, { "1.1.1.2" => "even" }, { "1.1.1.3" => [ "odd", "three" ] } ],
env: {
"clear" => { "REDIS_URL" => "redis://x/y", "THREE" => "false" },
"tags" => {
"odd" => { "TYPE" => "odd" },
"even" => { "TYPE" => "even" },
"three" => { "THREE" => "true" }
}
}
}
@config = Kamal::Configuration.new(@deploy)
@deploy_with_roles = @deploy.dup.merge({
servers: {
"web" => [ { "1.1.1.1" => "odd" }, "1.1.1.2" ],
"workers" => {
"hosts" => [ { "1.1.1.3" => [ "odd", "oddjob" ] }, "1.1.1.4" ],
"cmd" => "bin/jobs",
"env" => {
"REDIS_URL" => "redis://a/b",
"WEB_CONCURRENCY" => 4
}
}
},
env: {
"tags" => {
"odd" => { "TYPE" => "odd" },
"oddjob" => { "TYPE" => "oddjob" }
}
}
})
@config_with_roles = Kamal::Configuration.new(@deploy_with_roles)
end
test "tags" do
assert_equal 3, @config.env_tags.size
assert_equal %w[ odd even three ], @config.env_tags.map(&:name)
assert_equal({ "TYPE" => "odd" }, @config.env_tag("odd").env.clear)
assert_equal({ "TYPE" => "even" }, @config.env_tag("even").env.clear)
assert_equal({ "THREE" => "true" }, @config.env_tag("three").env.clear)
end
test "tags with roles" do
assert_equal 2, @config_with_roles.env_tags.size
assert_equal %w[ odd oddjob ], @config_with_roles.env_tags.map(&:name)
assert_equal({ "TYPE" => "odd" }, @config_with_roles.env_tag("odd").env.clear)
assert_equal({ "TYPE" => "oddjob" }, @config_with_roles.env_tag("oddjob").env.clear)
end
test "tag overrides env" do
assert_equal "false", @config.role("web").env("1.1.1.1").clear["THREE"]
assert_equal "true", @config.role("web").env("1.1.1.3").clear["THREE"]
end
test "later tag wins" do
deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ { "1.1.1.1" => [ "first", "second" ] } ],
env: {
"tags" => {
"first" => { "TYPE" => "first" },
"second" => { "TYPE" => "second" }
}
}
}
config = Kamal::Configuration.new(deploy)
assert_equal "second", config.role("web").env("1.1.1.1").clear["TYPE"]
end
test "tag secret env" do
ENV["PASSWORD"] = "hello"
deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ { "1.1.1.1" => "secrets" } ],
env: {
"tags" => {
"secrets" => { "secret" => [ "PASSWORD" ] }
}
}
}
config = Kamal::Configuration.new(deploy)
assert_equal "hello", config.role("web").env("1.1.1.1").secrets["PASSWORD"]
ensure
ENV.delete "PASSWORD"
end
test "tag clear env" do
deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ { "1.1.1.1" => "clearly" } ],
env: {
"tags" => {
"clearly" => { "clear" => { "FOO" => "bar" } }
}
}
}
config = Kamal::Configuration.new(deploy)
assert_equal "bar", config.role("web").env("1.1.1.1").clear["FOO"]
end
end

View File

@@ -57,10 +57,10 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
end end
test "env overwritten by role" do test "env overwritten by role" do
assert_equal "redis://a/b", @config_with_roles.role(:workers).env("1.1.1.3").clear["REDIS_URL"] assert_equal "redis://a/b", @config_with_roles.role(:workers).env.clear["REDIS_URL"]
assert_equal "\n", @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string assert_equal "\n", @config_with_roles.role(:workers).env.secrets_io.string
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args
end end
test "container name" do test "container name" do
@@ -73,7 +73,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
end end
test "env args" do test "env args" do
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args
end end
test "env secret overwritten by role" do test "env secret overwritten by role" do
@@ -104,8 +104,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
DB_PASSWORD=secret&\"123 DB_PASSWORD=secret&\"123
ENV ENV
assert_equal expected_secrets_file, @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string assert_equal expected_secrets_file, @config_with_roles.role(:workers).env.secrets_io.string
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args
ensure ensure
ENV["REDIS_PASSWORD"] = nil ENV["REDIS_PASSWORD"] = nil
ENV["DB_PASSWORD"] = nil ENV["DB_PASSWORD"] = nil
@@ -128,8 +128,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
DB_PASSWORD=secret123 DB_PASSWORD=secret123
ENV ENV
assert_equal expected_secrets_file, @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string assert_equal expected_secrets_file, @config_with_roles.role(:workers).env.secrets_io.string
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args
ensure ensure
ENV["DB_PASSWORD"] = nil ENV["DB_PASSWORD"] = nil
end end
@@ -150,8 +150,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
REDIS_PASSWORD=secret456 REDIS_PASSWORD=secret456
ENV ENV
assert_equal expected_secrets_file, @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string assert_equal expected_secrets_file, @config_with_roles.role(:workers).env.secrets_io.string
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args
ensure ensure
ENV["REDIS_PASSWORD"] = nil ENV["REDIS_PASSWORD"] = nil
end end
@@ -178,14 +178,14 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
REDIS_PASSWORD=secret456 REDIS_PASSWORD=secret456
ENV ENV
assert_equal expected_secrets_file, @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string assert_equal expected_secrets_file, @config_with_roles.role(:workers).env.secrets_io.string
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://c/d\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://c/d\"" ], @config_with_roles.role(:workers).env_args
ensure ensure
ENV["REDIS_PASSWORD"] = nil ENV["REDIS_PASSWORD"] = nil
end end
test "env secrets_file" do test "env secrets_file" do
assert_equal ".kamal/env/roles/app-workers.env", @config_with_roles.role(:workers).env("1.1.1.3").secrets_file assert_equal ".kamal/env/roles/app-workers.env", @config_with_roles.role(:workers).env.secrets_file
end end
test "asset path and volume args" do test "asset path and volume args" do

View File

@@ -44,7 +44,6 @@ class ConfigurationTest < ActiveSupport::TestCase
test "service name valid" do test "service name valid" do
assert Kamal::Configuration.new(@deploy.tap { _1[:service] = "hey-app1_primary" }).valid? assert Kamal::Configuration.new(@deploy.tap { _1[:service] = "hey-app1_primary" }).valid?
assert Kamal::Configuration.new(@deploy.tap { _1[:service] = "MyApp" }).valid?
end end
test "service name invalid" do test "service name invalid" do
@@ -83,15 +82,6 @@ class ConfigurationTest < ActiveSupport::TestCase
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3" ], config.proxy_hosts assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3" ], config.proxy_hosts
end end
test "filtered proxy hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2" ], @config_with_roles.proxy_hosts
@deploy_with_roles[:servers]["workers"]["proxy"] = true
config = Kamal::Configuration.new(@deploy_with_roles)
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3" ], config.proxy_hosts
end
test "version no git repo" do test "version no git repo" do
ENV.delete("VERSION") ENV.delete("VERSION")
@@ -154,6 +144,10 @@ class ConfigurationTest < ActiveSupport::TestCase
assert_equal "app-missing", @config.service_with_version assert_equal "app-missing", @config.service_with_version
end end
test "healthcheck service" do
assert_equal "healthcheck-app", @config.healthcheck_service
end
test "valid config" do test "valid config" do
assert @config.valid? assert @config.valid?
assert @config_with_roles.valid? assert @config_with_roles.valid?
@@ -267,7 +261,8 @@ class ConfigurationTest < ActiveSupport::TestCase
sshkit: {}, sshkit: {},
volume_args: [ "--volume", "/local/path:/container/path" ], volume_args: [ "--volume", "/local/path:/container/path" ],
builder: {}, builder: {},
logging: [ "--log-opt", "max-size=\"10m\"" ] } logging: [ "--log-opt", "max-size=\"10m\"" ],
healthcheck: { "path"=>"/up", "port"=>3000, "max_attempts" => 7, "exposed_port" => 3999, "log_lines" => 50 } }
assert_equal expected_config, @config.to_h assert_equal expected_config, @config.to_h
end end

View File

@@ -11,33 +11,6 @@ class EnvFileTest < ActiveSupport::TestCase
Kamal::EnvFile.new(env).to_s Kamal::EnvFile.new(env).to_s
end end
test "to_str won't escape chinese characters" do
env = {
"foo" => '你好 means hello, "欢迎" means welcome, that\'s simple! 😃 {smile}'
}
assert_equal "foo=你好 means hello, \"欢迎\" means welcome, that's simple! 😃 {smile}\n",
Kamal::EnvFile.new(env).to_s
end
test "to_s won't escape japanese characters" do
env = {
"foo" => 'こんにちは means hello, "ようこそ" means welcome, that\'s simple! 😃 {smile}'
}
assert_equal "foo=こんにちは means hello, \"ようこそ\" means welcome, that's simple! 😃 {smile}\n", \
Kamal::EnvFile.new(env).to_s
end
test "to_s won't escape korean characters" do
env = {
"foo" => '안녕하세요 means hello, "어서 오십시오" means welcome, that\'s simple! 😃 {smile}'
}
assert_equal "foo=안녕하세요 means hello, \"어서 오십시오\" means welcome, that's simple! 😃 {smile}\n", \
Kamal::EnvFile.new(env).to_s
end
test "to_s empty" do test "to_s empty" do
assert_equal "\n", Kamal::EnvFile.new({}).to_s assert_equal "\n", Kamal::EnvFile.new({}).to_s
end end

View File

@@ -1,28 +0,0 @@
service: app
image: dhh/app
servers:
web:
- 1.1.1.1: site1
- 1.1.1.2: [ site1 experimental ]
- 1.2.1.1: site2
- 1.2.1.2: site2
workers:
- 1.1.1.3: site1
- 1.1.1.4: site1
- 1.2.1.3: site2
- 1.2.1.4: [ site2 experimental ]
env:
clear:
TEST: "root"
EXPERIMENT: "disabled"
tags:
site1:
SITE: site1
site2:
SITE: site2
experimental:
EXPERIMENT: "enabled"
registry:
username: user
password: pw

View File

@@ -1,45 +0,0 @@
service: app
image: dhh/app
servers:
web:
- "1.1.1.1"
- "1.1.1.2"
workers:
- "1.1.1.3"
- "1.1.1.4"
registry:
username: user
password: pw
accessories:
mysql:
image: mysql:5.7
host: 1.1.1.3
port: 3306
env:
clear:
MYSQL_ROOT_HOST: '%'
secret:
- MYSQL_ROOT_PASSWORD
files:
- test/fixtures/files/my.cnf:/etc/mysql/my.cnf
directories:
- data:/var/lib/mysql
redis:
image: redis:latest
roles:
- web
port: 6379
directories:
- data:/data
readiness_delay: 0
ssh:
user: root
port: 22
builder:
remote:
arch: amd64
host: ssh://app@1.1.1.5:2122

View File

@@ -1,15 +0,0 @@
service: app
image: dhh/app
servers:
workers:
hosts:
- 1.1.1.1
web:
hosts:
- 1.1.1.1
env:
REDIS_URL: redis://x/y
registry:
server: registry.digitalocean.com
username: user
password: pw

View File

@@ -1,8 +0,0 @@
service: app
image: dhh/app
servers:
- "this-hostname-with-random-part-is-too-long.example.com"
- "this-hostname-is-really-unacceptably-long-to-be-honest.example.com"
registry:
username: user
password: pw

View File

@@ -1,39 +0,0 @@
service: app
image: dhh/app
servers:
web:
- "1.1.1.1"
- "1.1.1.2"
workers:
- "1.1.1.3"
- "1.1.1.4"
registry:
username: user
password: pw
accessories:
mysql:
image: mysql:5.7
host: 1.1.1.3
port: 3306
env:
clear:
MYSQL_ROOT_HOST: '%'
secret:
- MYSQL_ROOT_PASSWORD
files:
- test/fixtures/files/my.cnf:/etc/mysql/my.cnf
directories:
- data:/var/lib/mysql
redis:
image: redis:latest
roles:
- web
port: 6379
directories:
- data:/data
readiness_delay: 0
builder:
context: "."

View File

@@ -12,7 +12,7 @@ class IntegrationAppTest < IntegrationTest
kamal :app, :stop kamal :app, :stop
assert_app_is_down response_code: "504" assert_app_is_down response_code: "502"
kamal :app, :start kamal :app, :start
@@ -26,7 +26,7 @@ class IntegrationAppTest < IntegrationTest
logs = kamal :app, :logs, capture: true logs = kamal :app, :logs, capture: true
assert_match /App Host: vm1/, logs assert_match /App Host: vm1/, logs
assert_match /App Host: vm2/, logs assert_match /App Host: vm2/, logs
assert_match /GET \/up HTTP\/1.1/, logs assert_match /GET \/ HTTP\/1.1/, logs
images = kamal :app, :images, capture: true images = kamal :app, :images, capture: true
assert_match /App Host: vm1/, images assert_match /App Host: vm1/, images
@@ -52,6 +52,6 @@ class IntegrationAppTest < IntegrationTest
kamal :app, :remove kamal :app, :remove
assert_app_is_down response_code: "504" assert_app_is_down response_code: "502"
end end
end end

View File

@@ -1,33 +0,0 @@
require_relative "integration_test"
class BrokenDeployTest < IntegrationTest
test "deploying a bad image" do
@app = "app_with_roles"
kamal :envify
first_version = latest_app_version
kamal :deploy
assert_app_is_up version: first_version
assert_container_running host: :vm3, name: "app-workers-#{first_version}"
second_version = break_app
output = kamal :deploy, raise_on_error: false, capture: true
assert_failed_deploy output
assert_app_is_up version: first_version
assert_container_running host: :vm3, name: "app-workers-#{first_version}"
assert_container_not_running host: :vm3, name: "app-workers-#{second_version}"
end
private
def assert_failed_deploy(output)
assert_match "Waiting for the first healthy web container before booting workers on vm3...", output
assert_match /First web container is unhealthy on vm[12], not booting other roles/, output
assert_match "First web container is unhealthy, not booting workers on vm3", output
assert_match "nginx: [emerg] unexpected end of file, expecting \";\" or \"}\" in /etc/nginx/conf.d/default.conf:2", output
end
end

View File

@@ -18,7 +18,7 @@ services:
build: build:
context: docker/deployer context: docker/deployer
environment: environment:
- TEST_ID=${TEST_ID:-} - TEST_ID=${TEST_ID}
volumes: volumes:
- ../..:/kamal - ../..:/kamal
- shared:/shared - shared:/shared

View File

@@ -1,2 +1 @@
SECRET_TOKEN='1234 with "中文"' SECRET_TOKEN=1234
SECRET_TAG='TAGME'

View File

@@ -2,20 +2,14 @@ service: app
image: app image: app
servers: servers:
- vm1 - vm1
- vm2: [ tag1, tag2 ] - vm2
port: 80
env: env:
clear: clear:
CLEAR_TOKEN: 4321 CLEAR_TOKEN: 4321
CLEAR_TAG: ""
HOST_TOKEN: "${HOST_TOKEN}" HOST_TOKEN: "${HOST_TOKEN}"
secret: secret:
- SECRET_TOKEN - SECRET_TOKEN
tags:
tag1:
CLEAR_TAG: tagged
tag2:
secret:
- SECRET_TAG
asset_path: /usr/share/nginx/html/versions asset_path: /usr/share/nginx/html/versions
registry: registry:
@@ -26,11 +20,10 @@ builder:
multiarch: false multiarch: false
args: args:
COMMIT_SHA: <%= `git rev-parse HEAD` %> COMMIT_SHA: <%= `git rev-parse HEAD` %>
healthcheck:
cmd: wget -qO- http://localhost > /dev/null || exit 1
proxy: proxy:
image: registry:4443/basecamp/kamal-proxy:latest image: registry:4443/dmcbreen/mproxy:latest
http_port: 80
https_port: 443
debug: true
accessories: accessories:
busybox: busybox:
service: custom-busybox service: custom-busybox
@@ -38,5 +31,4 @@ accessories:
cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done'
roles: roles:
- web - web
stop_wait_time: 1 stop_wait_time: 5
readiness_delay: 0

View File

@@ -1 +1 @@
SECRET_TOKEN='1234 with "中文"' SECRET_TOKEN=1234

View File

@@ -9,6 +9,7 @@ servers:
hosts: hosts:
- vm3 - vm3
cmd: sleep infinity cmd: sleep infinity
port: 80
asset_path: /usr/share/nginx/html/versions asset_path: /usr/share/nginx/html/versions
@@ -20,8 +21,10 @@ builder:
multiarch: false multiarch: false
args: args:
COMMIT_SHA: <%= `git rev-parse HEAD` %> COMMIT_SHA: <%= `git rev-parse HEAD` %>
healthcheck:
cmd: wget -qO- http://localhost > /dev/null || exit 1
proxy: proxy:
image: registry:4443/basecamp/kamal-proxy:latest image: registry:4443/dmcbreen/mproxy:latest
accessories: accessories:
busybox: busybox:
service: custom-busybox service: custom-busybox
@@ -30,4 +33,3 @@ accessories:
roles: roles:
- web - web
stop_wait_time: 1 stop_wait_time: 1
readiness_delay: 0

View File

@@ -1,3 +0,0 @@
#!/bin/bash
cd $1 && echo "bad nginx config" > default.conf && git commit -am 'Broken'

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/kamal-proxy latest push_image_to_registry_4443 dmcbreen/mproxy 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

@@ -78,11 +78,6 @@ class IntegrationTest < ActiveSupport::TestCase
latest_app_version latest_app_version
end end
def break_app
deployer_exec "./break_app.sh #{@app}", workdir: "/"
latest_app_version
end
def latest_app_version def latest_app_version
deployer_exec("git rev-parse HEAD", capture: true) deployer_exec("git rev-parse HEAD", capture: true)
end end
@@ -136,16 +131,4 @@ class IntegrationTest < ActiveSupport::TestCase
puts "Tried to get the response code again and got #{app_response.code}" puts "Tried to get the response code again and got #{app_response.code}"
end end
end end
def assert_container_running(host:, name:)
assert container_running?(host: host, name: name)
end
def assert_container_not_running(host:, name:)
assert_not container_running?(host: host, name: name)
end
def container_running?(host:, name:)
docker_compose("exec #{host} docker ps --filter=name=#{name} | tail -n+2", capture: true).tap { |x| p [ x, x.strip, x.strip.present? ] }.strip.present?
end
end end

View File

@@ -4,7 +4,8 @@ class IntegrationMainTest < IntegrationTest
test "envify, deploy, redeploy, rollback, details and audit" do test "envify, deploy, redeploy, rollback, details and audit" do
kamal :server, :bootstrap kamal :server, :bootstrap
kamal :envify kamal :envify
assert_env_files assert_local_env_file "SECRET_TOKEN=1234"
assert_remote_env_file "SECRET_TOKEN=1234"
remove_local_env_file remove_local_env_file
first_version = latest_app_version first_version = latest_app_version
@@ -14,7 +15,9 @@ class IntegrationMainTest < IntegrationTest
kamal :deploy kamal :deploy
assert_app_is_up version: first_version assert_app_is_up version: first_version
assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy" assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy"
assert_envs version: first_version assert_env :CLEAR_TOKEN, "4321", version: first_version
assert_env :HOST_TOKEN, "abcd", version: first_version
assert_env :SECRET_TOKEN, "1234", version: first_version
second_version = update_app_rev second_version = update_app_rev
@@ -33,7 +36,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\/kamal-proxy:latest/, details assert_match /dmcbreen\/mproxy: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
@@ -58,12 +61,6 @@ class IntegrationMainTest < IntegrationTest
assert_app_is_up version: version assert_app_is_up version: version
assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy" assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy"
assert_container_running host: :vm3, name: "app-workers-#{version}" assert_container_running host: :vm3, name: "app-workers-#{version}"
second_version = update_app_rev
kamal :redeploy
assert_app_is_up version: second_version
assert_container_running host: :vm3, name: "app-workers-#{second_version}"
end end
test "config" do test "config" do
@@ -81,6 +78,7 @@ class IntegrationMainTest < IntegrationTest
assert_equal({ user: "root", port: 22, keepalive: true, keepalive_interval: 30, log_level: :fatal }, config[:ssh_options]) assert_equal({ user: "root", port: 22, keepalive: true, keepalive_interval: 30, log_level: :fatal }, config[:ssh_options])
assert_equal({ "multiarch" => false, "args" => { "COMMIT_SHA" => version } }, config[:builder]) assert_equal({ "multiarch" => false, "args" => { "COMMIT_SHA" => version } }, config[:builder])
assert_equal [ "--log-opt", "max-size=\"10m\"" ], config[:logging] assert_equal [ "--log-opt", "max-size=\"10m\"" ], config[:logging]
assert_equal({ "path" => "/up", "port" => 3000, "max_attempts" => 7, "exposed_port" => 3999, "log_lines" => 50, "cmd"=>"wget -qO- http://localhost > /dev/null || exit 1" }, config[:healthcheck])
end end
test "setup and remove" do test "setup and remove" do
@@ -101,38 +99,16 @@ class IntegrationMainTest < IntegrationTest
assert_equal contents, deployer_exec("cat .env", capture: true) assert_equal contents, deployer_exec("cat .env", capture: true)
end end
def assert_envs(version:) def assert_env(key, value, version:)
assert_env :CLEAR_TOKEN, "4321", version: version, vm: :vm1 assert_equal "#{key}=#{value}", docker_compose("exec vm1 docker exec app-web-#{version} env | grep #{key}", capture: true)
assert_env :HOST_TOKEN, "abcd", version: version, vm: :vm1
assert_env :SECRET_TOKEN, "1234 with \"中文\"", version: version, vm: :vm1
assert_no_env :CLEAR_TAG, version: version, vm: :vm1
assert_no_env :SECRET_TAG, version: version, vm: :vm11
assert_env :CLEAR_TAG, "tagged", version: version, vm: :vm2
assert_env :SECRET_TAG, "TAGME", version: version, vm: :vm2
end
def assert_env(key, value, vm:, version:)
assert_equal "#{key}=#{value}", docker_compose("exec #{vm} docker exec app-web-#{version} env | grep #{key}", capture: true)
end
def assert_no_env(key, vm:, version:)
assert_raises(RuntimeError, /exit 1/) do
docker_compose("exec #{vm} docker exec app-web-#{version} env | grep #{key}", capture: true)
end
end
def assert_env_files
assert_local_env_file "SECRET_TOKEN='1234 with \"中文\"'\nSECRET_TAG='TAGME'"
assert_remote_env_file "SECRET_TOKEN=1234 with \"中文\"", vm: :vm1
assert_remote_env_file "SECRET_TOKEN=1234 with \"中文\"\nSECRET_TAG=TAGME", vm: :vm2
end end
def remove_local_env_file def remove_local_env_file
deployer_exec("rm .env") deployer_exec("rm .env")
end end
def assert_remote_env_file(contents, vm:) def assert_remote_env_file(contents)
assert_equal contents, docker_compose("exec #{vm} cat /root/.kamal/env/roles/app-web.env", capture: true) assert_equal contents, docker_compose("exec vm1 cat /root/.kamal/env/roles/app-web.env", capture: true)
end end
def assert_no_remote_env_file def assert_no_remote_env_file
@@ -164,4 +140,8 @@ class IntegrationMainTest < IntegrationTest
assert vm1_image_ids.any? assert vm1_image_ids.any?
assert vm1_container_ids.any? assert vm1_container_ids.any?
end end
def assert_container_running(host:, name:)
assert docker_compose("exec #{host} docker ps --filter=name=#{name} -q", capture: true).strip.present?
end
end end

View File

@@ -8,13 +8,13 @@ class IntegrationProxyTest < IntegrationTest
kamal :proxy, :boot kamal :proxy, :boot
assert_proxy_running assert_proxy_running
output = kamal :proxy, :reboot, "-y", "--verbose", capture: true output = kamal :proxy, :reboot, "-y", capture: true
assert_proxy_running assert_proxy_running
assert_hooks_ran "pre-proxy-reboot", "post-proxy-reboot" assert_hooks_ran "pre-proxy-reboot", "post-proxy-reboot"
assert_match /Rebooting proxy on vm1,vm2.../, output assert_match /Rebooting proxy on vm1,vm2.../, output
assert_match /Rebooted proxy on vm1,vm2/, output assert_match /Rebooted proxy on vm1,vm2/, output
output = kamal :proxy, :reboot, "--rolling", "-y", "--verbose", capture: true output = kamal :proxy, :reboot, "--rolling", "-y", capture: true
assert_proxy_running assert_proxy_running
assert_hooks_ran "pre-proxy-reboot", "post-proxy-reboot" assert_hooks_ran "pre-proxy-reboot", "post-proxy-reboot"
assert_match /Rebooting proxy on vm1.../, output assert_match /Rebooting proxy on vm1.../, output
@@ -53,11 +53,11 @@ class IntegrationProxyTest < IntegrationTest
private private
def assert_proxy_running def assert_proxy_running
assert_match %r{registry:4443/basecamp/kamal-proxy:latest "kamal-proxy run"}, proxy_details assert_match %r{registry:4443/dmcbreen/mproxy:latest "mproxy run"}, proxy_details
end end
def assert_proxy_not_running def assert_proxy_not_running
assert_no_match %r{registry:4443/basecamp/kamal-proxy:latest "kamal-proxy run"}, proxy_details assert_no_match %r{registry:4443/dmcbreen/mproxy:latest "mproxy run"}, proxy_details
end end
def proxy_details def proxy_details