Compare commits

..

4 Commits

Author SHA1 Message Date
Donal McBreen
a72d1f2718 WIP 2023-08-31 09:57:16 +01:00
Donal McBreen
4491867080 Move static config test 2023-08-31 09:52:58 +01:00
Donal McBreen
782b1979ab WIP 2023-08-31 09:44:10 +01:00
Donal McBreen
989d09e027 Copy env files to remote hosts
Setting env variables in the docker arguments requires having them on
the deploy host.

Instead we'll add two new commands `kamal env push` and
`kamal env delete` which will manage copying the environment as .env
files to the remote host.

Docker will pick up the file with `--env-file <path-to-file>`. Env files
will be stored under `<kamal run directory>/env`.

Running `kamal env push` will create env files for each role and
accessory, and traefik if required.

`kamal envify` has been updated to also push the env files.

By avoiding using `kamal envify` and creating the local and remote
secrets manually, you can now avoid accessing secrets needed
for the docker runtime environment locally. You will still need build
secrets.

One thing to note - the Docker doesn't parse the environment variables
in the env file, one result of this is that you can't specify multi-line
values - see https://github.com/moby/moby/issues/12997.

We maybe need to look docker config or docker secrets longer term to get
around this.

Hattip to @kevinmcconnell - this was all his idea.
2023-08-30 17:00:05 +01:00
159 changed files with 2115 additions and 4473 deletions

View File

@@ -1,25 +1,10 @@
name: CI
on:
on:
push:
branches:
- main
pull_request:
jobs:
rubocop:
name: RuboCop
runs-on: ubuntu-latest
env:
BUNDLE_ONLY: rubocop
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Ruby and install gems
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3.0
bundler-cache: true
- name: Run Rubocop
run: bundle exec rubocop --parallel
tests:
strategy:
matrix:
@@ -27,29 +12,17 @@ jobs:
- "2.7"
- "3.1"
- "3.2"
- "3.3"
gemfile:
- Gemfile
- gemfiles/ruby_2.7.gemfile
- gemfiles/rails_edge.gemfile
exclude:
- ruby-version: "2.7"
gemfile: Gemfile
- ruby-version: "2.7"
gemfile: gemfiles/rails_edge.gemfile
- ruby-version: "3.1"
gemfile: gemfiles/ruby_2.7.gemfile
- ruby-version: "3.2"
gemfile: gemfiles/ruby_2.7.gemfile
- ruby-version: "3.3"
gemfile: gemfiles/ruby_2.7.gemfile
continue-on-error: [false]
name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }}
runs-on: ubuntu-latest
continue-on-error: true
continue-on-error: ${{ matrix.continue-on-error }}
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Install Ruby
uses: ruby/setup-ruby@v1

View File

@@ -1,2 +0,0 @@
inherit_gem:
rubocop-rails-omakase: rubocop.yml

View File

@@ -1,8 +1,4 @@
source "https://rubygems.org"
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gemspec
group :rubocop do
gem "rubocop-rails-omakase", require: false
end

View File

@@ -1,171 +1,96 @@
PATH
remote: .
specs:
kamal (1.6.0)
kamal (0.16.1)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
concurrent-ruby (~> 1.2)
dotenv (~> 2.8)
ed25519 (~> 1.2)
net-ssh (~> 7.0)
sshkit (>= 1.22.2, < 2.0)
sshkit (~> 1.21)
thor (~> 1.2)
zeitwerk (~> 2.5)
GEM
remote: https://rubygems.org/
specs:
actionpack (7.1.2)
actionview (= 7.1.2)
activesupport (= 7.1.2)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
rack-session (>= 1.0.1)
actionpack (7.0.4.3)
actionview (= 7.0.4.3)
activesupport (= 7.0.4.3)
rack (~> 2.0, >= 2.2.0)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
actionview (7.1.2)
activesupport (= 7.1.2)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actionview (7.0.4.3)
activesupport (= 7.0.4.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activesupport (7.1.2)
base64
bigdecimal
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activesupport (7.0.4.3)
concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
ast (2.4.2)
base64 (0.2.0)
bcrypt_pbkdf (1.1.0)
bigdecimal (3.1.5)
builder (3.2.4)
concurrent-ruby (1.2.2)
connection_pool (2.4.1)
crass (1.0.6)
debug (1.9.1)
irb (~> 1.10)
reline (>= 0.3.8)
debug (1.7.2)
irb (>= 1.5.0)
reline (>= 0.3.1)
dotenv (2.8.1)
drb (2.2.0)
ruby2_keywords
ed25519 (1.3.0)
erubi (1.12.0)
i18n (1.14.1)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
io-console (0.7.1)
irb (1.11.0)
rdoc
reline (>= 0.3.8)
json (2.7.1)
language_server-protocol (3.17.0.3)
loofah (2.22.0)
io-console (0.6.0)
irb (1.6.3)
reline (>= 0.3.0)
loofah (2.20.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
minitest (5.20.0)
mocha (2.1.0)
nokogiri (>= 1.5.9)
method_source (1.0.0)
minitest (5.18.0)
mocha (2.0.2)
ruby2_keywords (>= 0.0.5)
mutex_m (0.2.0)
net-scp (4.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)
nokogiri (1.16.0-arm64-darwin)
net-ssh (7.1.0)
nokogiri (1.14.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.0-x86_64-darwin)
nokogiri (1.14.2-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.0-x86_64-linux)
nokogiri (1.14.2-x86_64-linux)
racc (~> 1.4)
parallel (1.24.0)
parser (3.3.0.5)
ast (~> 2.4.1)
racc
psych (5.1.2)
stringio
racc (1.7.3)
rack (3.0.8)
rack-session (2.0.0)
rack (>= 3.0.0)
racc (1.6.2)
rack (2.2.6.4)
rack-test (2.1.0)
rack (>= 1.3)
rackup (2.1.0)
rack (>= 3)
webrick (~> 1.8)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.1.2)
actionpack (= 7.1.2)
activesupport (= 7.1.2)
irb
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.1.0)
rdoc (6.6.2)
psych (>= 4.0.0)
regexp_parser (2.9.0)
reline (0.4.2)
io-console (~> 0.5)
rexml (3.2.6)
rubocop (1.62.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.31.2)
parser (>= 3.3.0.4)
rubocop-minitest (0.35.0)
rubocop (>= 1.61, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-performance (1.20.2)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.30.0, < 2.0)
rubocop-rails (2.24.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails-omakase (1.0.0)
rubocop
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-progressbar (1.13.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.5.0)
loofah (~> 2.19, >= 2.19.1)
railties (7.0.4.3)
actionpack (= 7.0.4.3)
activesupport (= 7.0.4.3)
method_source
rake (>= 12.2)
thor (~> 1.0)
zeitwerk (~> 2.5)
rake (13.0.6)
reline (0.3.3)
io-console (~> 0.5)
ruby2_keywords (0.0.5)
sshkit (1.22.2)
base64
mutex_m
sshkit (1.21.4)
net-scp (>= 1.1.2)
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
stringio (3.1.0)
thor (1.3.0)
thor (1.2.1)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0)
webrick (1.8.1)
zeitwerk (2.6.12)
zeitwerk (2.6.7)
PLATFORMS
arm64-darwin
@@ -177,7 +102,6 @@ DEPENDENCIES
kamal!
mocha
railties
rubocop-rails-omakase
BUNDLED WITH
2.4.3

View File

@@ -1,6 +1,6 @@
# Kamal: Deploy web apps anywhere
From bare metal to cloud VMs, deploy web apps anywhere with zero downtime. Kamal has the dynamic reverse-proxy Traefik hold requests while a new app container is started and the old one is stopped. 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 using Docker, deploy web apps anywhere with zero downtime. Kamal uses the dynamic reverse-proxy Traefik to hold requests, while the new app container is started and the old one is stopped — working 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).

View File

@@ -9,10 +9,10 @@ begin
Kamal::Cli::Main.start(ARGV)
rescue SSHKit::Runner::ExecuteError => e
puts " \e[31mERROR (#{e.cause.class}): #{e.message}\e[0m"
puts e.cause.backtrace if ENV["VERBOSE"]
puts e.cause.backtrace
exit 1
rescue => e
puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m"
puts e.backtrace if ENV["VERBOSE"]
puts e.backtrace
exit 1
end

View File

@@ -1,6 +0,0 @@
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gemspec path: "../"
gem "nokogiri", "~> 1.15.0"

View File

@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
spec.executables = %w[ kamal ]
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 "thor", "~> 1.2"
spec.add_dependency "dotenv", "~> 2.8"
@@ -20,7 +20,6 @@ Gem::Specification.new do |spec|
spec.add_dependency "ed25519", "~> 1.2"
spec.add_dependency "bcrypt_pbkdf", "~> 1.0"
spec.add_dependency "concurrent-ruby", "~> 1.2"
spec.add_dependency "base64", "~> 0.2"
spec.add_development_dependency "debug"
spec.add_development_dependency "mocha"

View File

@@ -5,6 +5,6 @@ require "active_support"
require "zeitwerk"
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.eager_load # We need all commands loaded.

View File

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

View File

@@ -1,15 +1,15 @@
class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "boot [NAME]", "Boot new accessory service on host (use NAME=all to boot all accessories)"
def boot(name, login: true)
with_lock do
mutating do
if name == "all"
KAMAL.accessory_names.each { |accessory_name| boot(accessory_name) }
else
with_accessory(name) do |accessory, hosts|
with_accessory(name) do |accessory|
directories(name)
upload(name)
on(hosts) do
on(accessory.hosts) do
execute *KAMAL.registry.login if login
execute *KAMAL.auditor.record("Booted #{name} accessory"), verbosity: :debug
execute *accessory.run
@@ -21,9 +21,9 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "upload [NAME]", "Upload accessory files to host", hide: true
def upload(name)
with_lock do
with_accessory(name) do |accessory, hosts|
on(hosts) do
mutating do
with_accessory(name) do |accessory|
on(accessory.hosts) do
accessory.files.each do |(local, remote)|
accessory.ensure_local_file_present(local)
@@ -38,9 +38,9 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "directories [NAME]", "Create accessory directories on host", hide: true
def directories(name)
with_lock do
with_accessory(name) do |accessory, hosts|
on(hosts) do
mutating do
with_accessory(name) do |accessory|
on(accessory.hosts) do
accessory.directories.keys.each do |host_path|
execute *accessory.make_directory(host_path)
end
@@ -49,30 +49,26 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
end
end
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)"
def reboot(name)
with_lock do
if name == "all"
KAMAL.accessory_names.each { |accessory_name| reboot(accessory_name) }
else
with_accessory(name) do |accessory, hosts|
on(hosts) do
execute *KAMAL.registry.login
end
stop(name)
remove_container(name)
boot(name, login: false)
mutating do
with_accessory(name) do |accessory|
on(accessory.hosts) do
execute *KAMAL.registry.login
end
stop(name)
remove_container(name)
boot(name, login: false)
end
end
end
desc "start [NAME]", "Start existing accessory container on host"
def start(name)
with_lock do
with_accessory(name) do |accessory, hosts|
on(hosts) do
mutating do
with_accessory(name) do |accessory|
on(accessory.hosts) do
execute *KAMAL.auditor.record("Started #{name} accessory"), verbosity: :debug
execute *accessory.start
end
@@ -82,9 +78,9 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "stop [NAME]", "Stop existing accessory container on host"
def stop(name)
with_lock do
with_accessory(name) do |accessory, hosts|
on(hosts) do
mutating do
with_accessory(name) do |accessory|
on(accessory.hosts) do
execute *KAMAL.auditor.record("Stopped #{name} accessory"), verbosity: :debug
execute *accessory.stop, raise_on_non_zero_exit: false
end
@@ -94,7 +90,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "restart [NAME]", "Restart existing accessory container on host"
def restart(name)
with_lock do
mutating do
with_accessory(name) do
stop(name)
start(name)
@@ -107,9 +103,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
if name == "all"
KAMAL.accessory_names.each { |accessory_name| details(accessory_name) }
else
type = "Accessory #{name}"
with_accessory(name) do |accessory, hosts|
on(hosts) { puts_by_host host, capture_with_info(*accessory.info), type: type }
with_accessory(name) do |accessory|
on(accessory.hosts) { puts capture_with_info(*accessory.info) }
end
end
end
@@ -118,7 +113,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
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"
def exec(name, cmd)
with_accessory(name) do |accessory, hosts|
with_accessory(name) do |accessory|
case
when options[:interactive] && options[:reuse]
say "Launching interactive command with via SSH from existing container...", :magenta
@@ -130,14 +125,14 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
when options[:reuse]
say "Launching command from existing container...", :magenta
on(hosts) do
on(accessory.hosts) do
execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
capture_with_info(*accessory.execute_in_existing_container(cmd))
end
else
say "Launching command from new container...", :magenta
on(hosts) do
on(accessory.hosts) do
execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
capture_with_info(*accessory.execute_in_new_container(cmd))
end
@@ -151,12 +146,12 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)"
option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)"
def logs(name)
with_accessory(name) do |accessory, hosts|
with_accessory(name) do |accessory|
grep = options[:grep]
if options[:follow]
run_locally do
info "Following logs on #{hosts}..."
info "Following logs on #{accessory.hosts}..."
info accessory.follow_logs(grep: grep)
exec accessory.follow_logs(grep: grep)
end
@@ -164,7 +159,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
since = options[:since]
lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
on(hosts) do
on(accessory.hosts) do
puts capture_with_info(*accessory.logs(since: since, lines: lines, grep: grep))
end
end
@@ -174,12 +169,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)"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def remove(name)
confirming "This will remove all containers, images and data directories for #{name}. Are you sure?" do
with_lock do
if name == "all"
KAMAL.accessory_names.each { |accessory_name| remove_accessory(accessory_name) }
else
remove_accessory(name)
mutating do
if name == "all"
KAMAL.accessory_names.each { |accessory_name| remove(accessory_name) }
else
if options[:confirmed] || ask("This will remove all containers, images and data directories for #{name}. Are you sure?", limited_to: %w( y N ), default: "N") == "y"
with_accessory(name) do
stop(name)
remove_container(name)
remove_image(name)
remove_service_directory(name)
end
end
end
end
@@ -187,9 +187,9 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "remove_container [NAME]", "Remove accessory container from host", hide: true
def remove_container(name)
with_lock do
with_accessory(name) do |accessory, hosts|
on(hosts) do
mutating do
with_accessory(name) do |accessory|
on(accessory.hosts) do
execute *KAMAL.auditor.record("Remove #{name} accessory container"), verbosity: :debug
execute *accessory.remove_container
end
@@ -199,9 +199,9 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "remove_image [NAME]", "Remove accessory image from host", hide: true
def remove_image(name)
with_lock do
with_accessory(name) do |accessory, hosts|
on(hosts) do
mutating do
with_accessory(name) do |accessory|
on(accessory.hosts) do
execute *KAMAL.auditor.record("Removed #{name} accessory image"), verbosity: :debug
execute *accessory.remove_image
end
@@ -211,9 +211,9 @@ 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
def remove_service_directory(name)
with_lock do
with_accessory(name) do |accessory, hosts|
on(hosts) do
mutating do
with_accessory(name) do |accessory|
on(accessory.hosts) do
execute *accessory.remove_service_directory
end
end
@@ -222,9 +222,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
private
def with_accessory(name)
if KAMAL.config.accessory(name)
accessory = KAMAL.accessory(name)
yield accessory, accessory_hosts(accessory)
if accessory = KAMAL.accessory(name)
yield accessory
else
error_on_missing_accessory(name)
end
@@ -237,21 +236,4 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
"No accessory by the name of '#{name}'" +
(options ? " (options: #{options.to_sentence})" : "")
end
def accessory_hosts(accessory)
if KAMAL.specific_hosts&.any?
KAMAL.specific_hosts & accessory.hosts
else
accessory.hosts
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

View File

@@ -1,45 +1,76 @@
class Kamal::Cli::App < Kamal::Cli::Base
desc "boot", "Boot app on servers (or reboot app if already running)"
def boot
with_lock do
say "Get most recent version available as an image...", :magenta unless options[:version]
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
mutating do
ensure_traefik_file_provider_enabled
# Assets are prepared in a separate step to ensure they are on all hosts before booting
on(KAMAL.hosts) do
KAMAL.roles_on(host).each do |role|
Kamal::Cli::App::PrepareAssets.new(host, role, self).run
hold_lock_on_error do
say "Get most recent version available as an image...", :magenta unless options[:version]
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
on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug
execute *KAMAL.app.tag_current_as_latest
end
end
# Primary hosts and roles are returned first, so they can open the barrier
barrier = Kamal::Cli::Healthcheck::Barrier.new if KAMAL.roles.many?
on(KAMAL.hosts, **KAMAL.boot_strategy) do |host|
roles = KAMAL.roles_on(host)
on(KAMAL.hosts, **KAMAL.boot_strategy) do |host|
KAMAL.roles_on(host).each do |role|
Kamal::Cli::App::Boot.new(host, role, self, version, barrier).run
roles.each do |role|
app = KAMAL.app(role: role)
auditor = KAMAL.auditor(role: role)
traefik_dynamic = KAMAL.traefik_dynamic(role: role)
role_config = KAMAL.config.role(role)
if capture_with_info(*app.container_id_for_version(version, only_running: true), raise_on_non_zero_exit: false).present?
tmp_version = "#{version}_replaced_#{SecureRandom.hex(8)}"
info "Renaming container #{version} to #{tmp_version} as already deployed on #{host}"
execute *auditor.record("Renaming container #{version} to #{tmp_version}"), verbosity: :debug
execute *app.rename_container(version: version, new_version: tmp_version)
end
execute *auditor.record("Booted app version #{version}"), verbosity: :debug
old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
execute *app.start_or_run(hostname: "#{host}-#{SecureRandom.hex(6)}")
Kamal::Utils::HealthcheckPoller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) }
if role_config.running_traefik?
ip_address = capture_with_info(*app.ip_address(version: version)).strip
execute *traefik_dynamic.write_config(ip_address: ip_address)
Kamal::Utils::SwitchPoller.wait_for_switch(traefik_dynamic) { capture_with_info(*traefik_dynamic.run_id)&.strip }
end
execute *app.stop(version: old_version), raise_on_non_zero_exit: false if old_version.present?
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
desc "start", "Start existing app container on servers"
def start
with_lock do
mutating do
ensure_traefik_file_provider_enabled
on(KAMAL.hosts) do |host|
roles = KAMAL.roles_on(host)
roles.each do |role|
app = KAMAL.app(role: role)
role_config = KAMAL.config.role(role)
execute *KAMAL.auditor.record("Started app version #{KAMAL.config.version}"), verbosity: :debug
execute *KAMAL.app(role: role, host: host).start, raise_on_non_zero_exit: false
execute *app.start, raise_on_non_zero_exit: false
version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
if role_config.running_traefik?
ip_address = capture_with_info(*app.ip_address(version: version)).strip
execute *KAMAL.traefik_dynamic(role: role).write_config(ip_address: ip_address)
end
end
end
end
@@ -47,13 +78,15 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "stop", "Stop app container on servers"
def stop
with_lock do
mutating do
on(KAMAL.hosts) do |host|
roles = KAMAL.roles_on(host)
roles.each do |role|
app = KAMAL.app(role: role)
execute *KAMAL.auditor.record("Stopped app", role: role), verbosity: :debug
execute *KAMAL.app(role: role, host: host).stop, raise_on_non_zero_exit: false
execute *KAMAL.traefik_dynamic(role: role).remove_config if KAMAL.config.role(role).running_traefik?
execute *app.stop, raise_on_non_zero_exit: false
end
end
end
@@ -66,32 +99,28 @@ class Kamal::Cli::App < Kamal::Cli::Base
roles = KAMAL.roles_on(host)
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
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 :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)
env = options[:env]
case
when options[:interactive] && options[:reuse]
say "Get current version of running container...", :magenta unless options[: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
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: "web").execute_in_existing_container_over_ssh(cmd, host: KAMAL.primary_host) }
end
when options[:interactive]
say "Get most recent version available as an image...", :magenta unless options[: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
run_locally do
exec KAMAL.app(role: KAMAL.primary_role, host: KAMAL.primary_host).execute_in_new_container_over_ssh(cmd, env: env)
end
run_locally { exec KAMAL.app(role: KAMAL.roles_on(KAMAL.primary_host).first).execute_in_new_container_over_ssh(cmd, host: KAMAL.primary_host) }
end
when options[:reuse]
@@ -104,7 +133,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
roles.each do |role|
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
@@ -114,12 +143,8 @@ class Kamal::Cli::App < Kamal::Cli::Base
using_version(version_or_latest) do |version|
say "Launching command with version #{version} from new container...", :magenta
on(KAMAL.hosts) do |host|
roles = KAMAL.roles_on(host)
roles.each do |role|
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))
end
execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
puts_by_host host, capture_with_info(*KAMAL.app.execute_in_new_container(cmd))
end
end
end
@@ -133,21 +158,19 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "stale_containers", "Detect app stale containers"
option :stop, aliases: "-s", type: :boolean, default: false, desc: "Stop the stale containers found"
def stale_containers
stop = options[:stop]
mutating do
stop = options[:stop]
cli = self
with_lock_if_stopping do
on(KAMAL.hosts) do |host|
roles = KAMAL.roles_on(host)
roles.each do |role|
app = KAMAL.app(role: role, host: host)
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|
cli.send(:stale_versions, host: host, role: role).each do |version|
if stop
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
puts_by_host host, "Detected stale container for role #{role} with version #{version} (use `kamal app stale_containers --stop` to stop)"
end
@@ -171,21 +194,19 @@ class Kamal::Cli::App < Kamal::Cli::Base
# FIXME: Catch when app containers aren't running
grep = options[:grep]
since = options[:since]
if options[:follow]
lines = options[:lines].presence || ((since || grep) ? nil : 10) # Default to 10 lines if since or grep isn't set
if options[:follow]
run_locally do
info "Following logs on #{KAMAL.primary_host}..."
KAMAL.specific_roles ||= [ "web" ]
KAMAL.specific_roles ||= ["web"]
role = KAMAL.roles_on(KAMAL.primary_host).first
app = KAMAL.app(role: role, host: host)
info app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep)
exec app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep)
info KAMAL.app(role: role).follow_logs(host: KAMAL.primary_host, grep: grep)
exec KAMAL.app(role: role).follow_logs(host: KAMAL.primary_host, grep: grep)
end
else
since = options[:since]
lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
on(KAMAL.hosts) do |host|
@@ -193,7 +214,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
roles.each do |role|
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
puts_by_host host, "Nothing found"
end
@@ -204,7 +225,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "remove", "Remove app containers and images from servers"
def remove
with_lock do
mutating do
stop
remove_containers
remove_images
@@ -213,13 +234,13 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "remove_container [VERSION]", "Remove app container with given version from servers", hide: true
def remove_container(version)
with_lock do
mutating do
on(KAMAL.hosts) do |host|
roles = KAMAL.roles_on(host)
roles.each do |role|
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
@@ -227,13 +248,13 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "remove_containers", "Remove all app containers from servers", hide: true
def remove_containers
with_lock do
mutating do
on(KAMAL.hosts) do |host|
roles = KAMAL.roles_on(host)
roles.each do |role|
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
@@ -241,7 +262,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "remove_images", "Remove all app images from servers", hide: true
def remove_images
with_lock do
mutating do
on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Removed all app images"), verbosity: :debug
execute *KAMAL.app.remove_images
@@ -253,7 +274,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
def version
on(KAMAL.hosts) do |host|
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
@@ -276,20 +297,32 @@ class Kamal::Cli::App < Kamal::Cli::Base
version = nil
on(host) do
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
version.presence
end
def version_or_latest
options[:version] || KAMAL.config.latest_tag
def stale_versions(host:, role:)
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
def with_lock_if_stopping
if options[:stop]
with_lock { yield }
else
yield
def version_or_latest
options[:version] || "latest"
end
def ensure_traefik_file_provider_enabled
# Ensure traefik has been rebooted to switch to the file provider
on(KAMAL.traefik_hosts) do
unless capture_with_info(*KAMAL.traefik_static.docker_entrypoint_args).include?("--providers.file.directory=")
raise Kamal::Cli::TraefikError, "File provider not enabled, you'll need to run `kamal traefik reboot` to deploy"
end
end
end
end

View File

@@ -1,119 +0,0 @@
class Kamal::Cli::App::Boot
attr_reader :host, :role, :version, :barrier, :sshkit
delegate :execute, :capture_with_info, :capture_with_pretty_json, :info, :error, to: :sshkit
delegate :uses_cord?, :assets?, :running_traefik?, to: :role
def initialize(host, role, sshkit, version, barrier)
@host = host
@role = role
@version = version
@barrier = barrier
@sshkit = sshkit
end
def run
old_version = old_version_renamed_if_clashing
wait_at_barrier if queuer?
begin
start_new_version
rescue => e
close_barrier if gatekeeper?
stop_new_version
raise
end
release_barrier if gatekeeper?
if old_version
stop_old_version(old_version)
end
end
private
def old_version_renamed_if_clashing
if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present?
renamed_version = "#{version}_replaced_#{SecureRandom.hex(8)}"
info "Renaming container #{version} to #{renamed_version} as already deployed on #{host}"
audit("Renaming container #{version} to #{renamed_version}")
execute *app.rename_container(version: version, new_version: renamed_version)
end
capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip.presence
end
def start_new_version
audit "Booted app version #{version}"
execute *app.tie_cord(role.cord_host_file) if uses_cord?
hostname = "#{host.to_s[0...51].gsub(/\.+$/, '')}-#{SecureRandom.hex(6)}"
execute *app.run(hostname: hostname)
Kamal::Cli::Healthcheck::Poller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) }
end
def stop_new_version
execute *app.stop(version: version), raise_on_non_zero_exit: false
end
def stop_old_version(version)
if uses_cord?
cord = capture_with_info(*app.cord(version: version), raise_on_non_zero_exit: false).strip
if cord.present?
execute *app.cut_cord(cord)
Kamal::Cli::Healthcheck::Poller.wait_for_unhealthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) }
end
end
execute *app.stop(version: version), raise_on_non_zero_exit: false
execute *app.clean_up_assets if assets?
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::Healthcheck::Error
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))
error capture_with_info(*app.container_health_log(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

View File

@@ -1,24 +0,0 @@
class Kamal::Cli::App::PrepareAssets
attr_reader :host, :role, :sshkit
delegate :execute, :capture_with_info, :info, to: :sshkit
delegate :assets?, to: :role
def initialize(host, role, sshkit)
@host = host
@role = role
@sshkit = sshkit
end
def run
if assets?
execute *app.extract_assets
old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
execute *app.sync_asset_volumes(old_version: old_version)
end
end
private
def app
@app ||= KAMAL.app(role: role, host: host)
end
end

View File

@@ -14,8 +14,8 @@ module Kamal::Cli
class_option :version, desc: "Run commands against a specific app version"
class_option :primary, type: :boolean, aliases: "-p", desc: "Run commands only on primary host instead of all"
class_option :hosts, aliases: "-h", desc: "Run commands on these hosts instead of all (separate by comma, supports wildcards with *)"
class_option :roles, aliases: "-r", desc: "Run commands on these roles instead of all (separate by comma, supports wildcards with *)"
class_option :hosts, aliases: "-h", desc: "Run commands on these hosts instead of all (separate by comma)"
class_option :roles, aliases: "-r", desc: "Run commands on these roles instead of all (separate by comma)"
class_option :config_file, aliases: "-c", default: "config/deploy.yml", desc: "Path to config file"
class_option :destination, aliases: "-d", desc: "Specify destination to be used for config file (staging -> deploy.staging.yml)"
@@ -24,7 +24,6 @@ module Kamal::Cli
def initialize(*)
super
@original_env = ENV.to_h.dup
load_envs
initialize_commander(options_with_subcommand_class_options)
end
@@ -38,12 +37,6 @@ module Kamal::Cli
end
end
def reload_envs
ENV.clear
ENV.update(@original_env)
load_envs
end
def options_with_subcommand_class_options
options.merge(@_initializer.last[:class_options] || {})
end
@@ -73,43 +66,36 @@ module Kamal::Cli
def print_runtime
started_at = Time.now
yield
Time.now - started_at
return Time.now - started_at
ensure
runtime = Time.now - started_at
puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
end
def with_lock
if KAMAL.holding_lock?
def mutating
return yield if KAMAL.holding_lock?
KAMAL.config.ensure_env_available
run_hook "pre-connect"
ensure_run_directory
acquire_lock
begin
yield
else
ensure_run_and_locks_directory
acquire_lock
begin
yield
rescue
begin
release_lock
rescue => e
say "Error releasing the deploy lock: #{e.message}", :red
end
raise
rescue
if KAMAL.hold_lock_on_error?
error " \e[31mDeploy lock was not released\e[0m"
else
release_lock
end
release_lock
raise
end
end
def confirming(question)
return yield if options[:confirmed]
if ask(question, limited_to: %w[ y N ], default: "N") == "y"
yield
else
say "Aborted", :red
end
release_lock
end
def acquire_lock
@@ -132,36 +118,36 @@ module Kamal::Cli
yield
rescue SSHKit::Runner::ExecuteError => e
if e.message =~ /cannot create directory/
say "Deploy lock already in place!", :red
on(KAMAL.primary_host) { puts capture_with_debug(*KAMAL.lock.status) }
raise LockError, "Deploy lock found. Run 'kamal lock help' for more information"
raise LockError, "Deploy lock found"
else
raise e
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)
if !options[:skip_hooks] && KAMAL.hook.hook_exists?(hook)
details = { hosts: KAMAL.hosts.join(","), command: command, subcommand: subcommand }
say "Running the #{hook} hook...", :magenta
run_locally do
execute *KAMAL.hook.run(hook, **details, **extra_details)
rescue SSHKit::Command::Failed => e
raise HookError.new("Hook `#{hook}` failed:\n#{e.message}")
KAMAL.with_verbosity(:debug) { execute *KAMAL.hook.run(hook, **details, **extra_details) }
rescue SSHKit::Command::Failed
raise HookError.new("Hook `#{hook}` failed")
end
end
end
def on(*args, &block)
if !KAMAL.connected?
run_hook "pre-connect"
KAMAL.connected = true
end
super
end
def command
@kamal_command ||= begin
invocation_class, invocation_commands = *first_invocation
@@ -184,14 +170,10 @@ module Kamal::Cli
instance_variable_get("@_invocations").first
end
def ensure_run_and_locks_directory
def ensure_run_directory
on(KAMAL.hosts) do
execute(*KAMAL.server.ensure_run_directory)
end
on(KAMAL.primary_host) do
execute(*KAMAL.lock.ensure_locks_directory)
end
end
end
end
end

View File

@@ -1,54 +1,41 @@
require "uri"
class Kamal::Cli::Build < Kamal::Cli::Base
class BuildError < StandardError; end
desc "deliver", "Build app and push app image to registry then pull image on servers"
def deliver
push
pull
mutating do
push
pull
end
end
desc "push", "Build and push app image to registry"
def push
cli = self
mutating do
cli = self
verify_local_dependencies
run_hook "pre-build"
verify_local_dependencies
run_hook "pre-build"
uncommitted_changes = Kamal::Git.uncommitted_changes
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
if (uncommitted_changes = Kamal::Utils.uncommitted_changes).present?
say "The following paths have uncommitted changes:\n #{uncommitted_changes}", :yellow
end
run_locally do
Clone.new(self).prepare
end
elsif uncommitted_changes.present?
say "Building with uncommitted changes:\n #{uncommitted_changes}", :yellow
end
# Get the command here to ensure the Dir.chdir doesn't interfere with it
push = KAMAL.builder.push
run_locally do
begin
KAMAL.with_verbosity(:debug) do
Dir.chdir(KAMAL.config.builder.build_directory) { execute *push }
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
begin
KAMAL.with_verbosity(:debug) do
execute *KAMAL.builder.push
end
rescue SSHKit::Command::Failed => e
if e.message =~ /(no builder)|(no such file or directory)/
error "Missing compatible builder, so creating a new one first"
if cli.create
KAMAL.with_verbosity(:debug) { execute *KAMAL.builder.push }
end
else
raise
end
else
raise
end
end
end
@@ -56,30 +43,29 @@ class Kamal::Cli::Build < Kamal::Cli::Base
desc "pull", "Pull app image from registry onto servers"
def pull
on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Pulled image with version #{KAMAL.config.version}"), verbosity: :debug
execute *KAMAL.builder.clean, raise_on_non_zero_exit: false
execute *KAMAL.builder.pull
execute *KAMAL.builder.validate_image
mutating do
on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Pulled image with version #{KAMAL.config.version}"), verbosity: :debug
execute *KAMAL.builder.clean, raise_on_non_zero_exit: false
execute *KAMAL.builder.pull
end
end
end
desc "create", "Create a build setup"
def create
if (remote_host = KAMAL.config.builder.remote_host)
connect_to_remote_host(remote_host)
end
run_locally do
begin
debug "Using builder: #{KAMAL.builder.name}"
execute *KAMAL.builder.create
rescue SSHKit::Command::Failed => e
if e.message =~ /stderr=(.*)/
error "Couldn't create remote builder: #{$1}"
false
else
raise
mutating do
run_locally do
begin
debug "Using builder: #{KAMAL.builder.name}"
execute *KAMAL.builder.create
rescue SSHKit::Command::Failed => e
if e.message =~ /stderr=(.*)/
error "Couldn't create remote builder: #{$1}"
false
else
raise
end
end
end
end
@@ -87,9 +73,11 @@ class Kamal::Cli::Build < Kamal::Cli::Base
desc "remove", "Remove build setup"
def remove
run_locally do
debug "Using builder: #{KAMAL.builder.name}"
execute *KAMAL.builder.remove
mutating do
run_locally do
debug "Using builder: #{KAMAL.builder.name}"
execute *KAMAL.builder.remove
end
end
end
@@ -115,17 +103,4 @@ class Kamal::Cli::Build < Kamal::Cli::Base
end
end
end
def connect_to_remote_host(remote_host)
remote_uri = URI.parse(remote_host)
if remote_uri.scheme == "ssh"
host = SSHKit::Host.new(
hostname: remote_uri.host,
ssh_options: { user: remote_uri.user, port: remote_uri.port }.compact
)
on(host, options) do
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,26 +3,26 @@ require "tempfile"
class Kamal::Cli::Env < Kamal::Cli::Base
desc "push", "Push the env file to the remote hosts"
def push
with_lock do
mutating do
on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Pushed env files"), verbosity: :debug
KAMAL.roles_on(host).each do |role|
execute *KAMAL.app(role: role, host: host).make_env_directory
upload! role.env(host).secrets_io, role.env(host).secrets_file, mode: 400
role_config = KAMAL.config.role(role)
execute *KAMAL.app(role: role).make_env_directory
upload! StringIO.new(role_config.env_file), role_config.host_env_file_path, mode: 400
end
end
on(KAMAL.traefik_hosts) do
execute *KAMAL.traefik.make_env_directory
upload! KAMAL.traefik.env.secrets_io, KAMAL.traefik.env.secrets_file, mode: 400
traefik_static_config = KAMAL.traefik_static.static_config
execute *KAMAL.traefik_static.make_env_directory
upload! StringIO.new(traefik_static_config.env_file), traefik_static_config.host_env_file_path, mode: 400
end
on(KAMAL.accessory_hosts) do
KAMAL.accessories_on(host).each do |accessory|
accessory_config = KAMAL.config.accessory(accessory)
execute *KAMAL.accessory(accessory).make_env_directory
upload! accessory_config.env.secrets_io, accessory_config.env.secrets_file, mode: 400
upload! StringIO.new(accessory_config.env_file), accessory_config.host_env_file_path, mode: 400
end
end
end
@@ -30,17 +30,16 @@ class Kamal::Cli::Env < Kamal::Cli::Base
desc "delete", "Delete the env file from the remote hosts"
def delete
with_lock do
mutating do
on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Deleted env files"), verbosity: :debug
KAMAL.roles_on(host).each do |role|
execute *KAMAL.app(role: role, host: host).remove_env_file
role_config = KAMAL.config.role(role)
execute *KAMAL.app(role: role).remove_env_file
end
end
on(KAMAL.traefik_hosts) do
execute *KAMAL.traefik.remove_env_file
execute *KAMAL.traefik_static.remove_env_file
end
on(KAMAL.accessory_hosts) do

View File

@@ -0,0 +1,20 @@
class Kamal::Cli::Healthcheck < Kamal::Cli::Base
default_command :perform
desc "perform", "Health check current app version"
def perform
on(KAMAL.primary_host) do
begin
execute *KAMAL.healthcheck.run
Kamal::Utils::HealthcheckPoller.wait_for_healthy { capture_with_info(*KAMAL.healthcheck.status) }
rescue Kamal::Utils::HealthcheckPoller::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

@@ -1,31 +0,0 @@
class Kamal::Cli::Healthcheck::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::Healthcheck::Error.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,2 +0,0 @@
class Kamal::Cli::Healthcheck::Error < StandardError
end

View File

@@ -1,63 +0,0 @@
module Kamal::Cli::Healthcheck::Poller
extend self
TRAEFIK_UPDATE_DELAY = 5
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 Kamal::Cli::Healthcheck::Error, "container not ready (#{status})"
end
rescue Kamal::Cli::Healthcheck::Error => 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 Kamal::Cli::Healthcheck::Error, "container not unhealthy (#{status})"
end
rescue Kamal::Cli::Healthcheck::Error => 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

@@ -1,18 +1,10 @@
class Kamal::Cli::Main < Kamal::Cli::Base
desc "setup", "Setup all accessories, push the env, and deploy app to servers"
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
desc "setup", "Setup all accessories and deploy app to servers"
def setup
print_runtime do
with_lock do
invoke_options = deploy_options
say "Ensure Docker is installed...", :magenta
invoke "kamal:cli:server:bootstrap", [], invoke_options
say "Evaluate and push env files...", :magenta
invoke "kamal:cli:main:envify", [], invoke_options
invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options
mutating do
invoke "kamal:cli:server:bootstrap"
invoke "kamal:cli:accessory:boot", [ "all" ]
deploy
end
end
@@ -22,27 +14,30 @@ class Kamal::Cli::Main < Kamal::Cli::Base
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
def deploy
runtime = print_runtime do
invoke_options = deploy_options
mutating do
invoke_options = deploy_options
say "Log into image registry...", :magenta
invoke "kamal:cli:registry:login", [], invoke_options
say "Log into image registry...", :magenta
invoke "kamal:cli:registry:login", [], invoke_options
if options[:skip_push]
say "Pull app image...", :magenta
invoke "kamal:cli:build:pull", [], invoke_options
else
say "Build and push app image...", :magenta
invoke "kamal:cli:build:deliver", [], invoke_options
end
if options[:skip_push]
say "Pull app image...", :magenta
invoke "kamal:cli:build:pull", [], invoke_options
else
say "Build and push app image...", :magenta
invoke "kamal:cli:build:deliver", [], invoke_options
end
with_lock do
run_hook "pre-deploy"
say "Ensure Traefik is running...", :magenta
invoke "kamal:cli:traefik:boot", [], invoke_options
say "Ensure app can pass healthcheck...", :magenta
invoke "kamal:cli:healthcheck:perform", [], invoke_options
say "Detect stale containers...", :magenta
invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)
invoke "kamal:cli:app:stale_containers", [], invoke_options
invoke "kamal:cli:app:boot", [], invoke_options
@@ -58,21 +53,24 @@ class Kamal::Cli::Main < Kamal::Cli::Base
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
def redeploy
runtime = print_runtime do
invoke_options = deploy_options
mutating do
invoke_options = deploy_options
if options[:skip_push]
say "Pull app image...", :magenta
invoke "kamal:cli:build:pull", [], invoke_options
else
say "Build and push app image...", :magenta
invoke "kamal:cli:build:deliver", [], invoke_options
end
if options[:skip_push]
say "Pull app image...", :magenta
invoke "kamal:cli:build:pull", [], invoke_options
else
say "Build and push app image...", :magenta
invoke "kamal:cli:build:deliver", [], invoke_options
end
with_lock do
run_hook "pre-deploy"
say "Ensure app can pass healthcheck...", :magenta
invoke "kamal:cli:healthcheck:perform", [], invoke_options
say "Detect stale containers...", :magenta
invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)
invoke "kamal:cli:app:stale_containers", [], invoke_options
invoke "kamal:cli:app:boot", [], invoke_options
end
@@ -85,7 +83,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
def rollback(version)
rolled_back = false
runtime = print_runtime do
with_lock do
mutating do
invoke_options = deploy_options
KAMAL.config.version = version
@@ -167,7 +165,6 @@ class Kamal::Cli::Main < Kamal::Cli::Base
end
desc "envify", "Create .env by evaluating .env.erb (or .env.staging.erb -> .env.staging when using -d staging)"
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip .env file push"
def envify
if destination = options[:destination]
env_template_path = ".env.#{destination}.erb"
@@ -177,23 +174,17 @@ class Kamal::Cli::Main < Kamal::Cli::Base
env_path = ".env"
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)).result, perm: 0600)
unless options[:skip_push]
reload_envs
invoke "kamal:cli:env:push", options
end
else
puts "Skipping envify (no #{env_template_path} exist)"
end
load_envs # reload new file
invoke "kamal:cli:env:push", options
end
desc "remove", "Remove Traefik, app, accessories, and registry session from servers"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def remove
confirming "This will remove all containers and images. Are you sure?" do
with_lock do
mutating do
if options[:confirmed] || ask("This will remove all containers and images. Are you sure?", limited_to: %w( y N ), default: "N") == "y"
invoke "kamal:cli:traefik:remove", [], options.without(:confirmed)
invoke "kamal:cli:app:remove", [], options.without(:confirmed)
invoke "kamal:cli:accessory:remove", [ "all" ], options
@@ -219,6 +210,9 @@ class Kamal::Cli::Main < Kamal::Cli::Base
desc "env", "Manage environment files"
subcommand "env", Kamal::Cli::Env
desc "healthcheck", "Healthcheck application"
subcommand "healthcheck", Kamal::Cli::Healthcheck
desc "lock", "Manage the deploy lock"
subcommand "lock", Kamal::Cli::Lock
@@ -239,11 +233,11 @@ class Kamal::Cli::Main < Kamal::Cli::Base
begin
on(KAMAL.hosts) do
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?
end
end
rescue SSHKit::Runner::ExecuteError, SSHKit::Runner::MultipleExecuteError => e
rescue SSHKit::Runner::ExecuteError => e
if e.message =~ /Container not found/
say "Error looking for container version #{version}: #{e.message}"
return false

View File

@@ -1,15 +1,15 @@
class Kamal::Cli::Prune < Kamal::Cli::Base
desc "all", "Prune unused images and stopped containers"
def all
with_lock do
mutating do
containers
images
end
end
desc "images", "Prune unused images"
desc "images", "Prune dangling images"
def images
with_lock do
mutating do
on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Pruned images"), verbosity: :debug
execute *KAMAL.prune.dangling_images
@@ -18,17 +18,12 @@ class Kamal::Cli::Prune < Kamal::Cli::Base
end
end
desc "containers", "Prune all stopped containers, except the last n (default 5)"
option :retain, type: :numeric, default: nil, desc: "Number of containers to retain"
desc "containers", "Prune all stopped containers, except the last 5"
def containers
retain = options.fetch(:retain, KAMAL.config.retain_containers)
raise "retain must be at least 1" if retain < 1
with_lock do
mutating do
on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Pruned containers"), verbosity: :debug
execute *KAMAL.prune.app_containers(retain: retain)
execute *KAMAL.prune.healthcheck_containers
execute *KAMAL.prune.containers
end
end
end

View File

@@ -1,49 +1,25 @@
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"
def bootstrap
with_lock do
missing = []
missing = []
on(KAMAL.hosts | KAMAL.accessory_hosts) do |host|
unless execute(*KAMAL.docker.installed?, raise_on_non_zero_exit: false)
if execute(*KAMAL.docker.superuser?, raise_on_non_zero_exit: false)
info "Missing Docker on #{host}. Installing…"
execute *KAMAL.docker.install
else
missing << host
end
on(KAMAL.hosts | KAMAL.accessory_hosts) do |host|
unless execute(*KAMAL.docker.installed?, raise_on_non_zero_exit: false)
if execute(*KAMAL.docker.superuser?, raise_on_non_zero_exit: false)
info "Missing Docker on #{host}. Installing…"
execute *KAMAL.docker.install
else
missing << host
end
execute(*KAMAL.server.ensure_run_directory)
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
on(KAMAL.hosts) do
execute(*KAMAL.server.ensure_run_directory)
end
run_hook "docker-setup"
if missing.any?
raise "Docker is not installed on #{missing.join(", ")} and can't be automatically installed without having root access and the `curl` command available. Install Docker manually: https://docs.docker.com/engine/install/"
end
end
end

View File

@@ -19,7 +19,6 @@ registry:
- KAMAL_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .env).
# Remember to run `kamal env push` after making changes!
# env:
# clear:
# DB_HOST: 192.168.0.2
@@ -53,7 +52,7 @@ registry:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
# - db/production.sql.erb:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
# redis:
@@ -63,7 +62,7 @@ registry:
# directories:
# - data:/data
# Configure custom arguments for Traefik. Be sure to reboot traefik when you modify it.
# Configure custom arguments for Traefik
# traefik:
# args:
# accesslog: true
@@ -73,29 +72,3 @@ registry:
# healthcheck:
# path: /healthz
# port: 4000
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
#
# If your app is using the Sprockets gem, ensure it sets `config.assets.manifest`.
# See https://github.com/basecamp/kamal/issues/626 for details
#
# asset_path: /rails/public/assets
# Configure rolling deploys by setting a wait time between batches of restarts.
# boot:
# limit: 10 # Can also specify as a percentage of total hosts, such as "25%"
# wait: 2
# Configure the role used to determine the primary_host. This host takes
# deploy locks, runs health checks during the deploy, and follow logs, etc.
#
# Caution: there's no support for role renaming yet, so be careful to cleanup
# the previous role on the deployed hosts.
# primary_role: web
# Controls if we abort when see a role with no hosts. Disabling this may be
# useful for more complex deploy configurations.
#
# allow_empty_roles: false

View File

@@ -1,7 +0,0 @@
#!/usr/bin/env ruby
# A sample docker-setup hook
#
# Sets up a Docker network which can then be used by the applications containers
ssh user@example.com docker network create kamal

View File

@@ -1,3 +0,0 @@
#!/bin/sh
echo "Rebooted Traefik on $KAMAL_HOSTS"

View File

@@ -32,7 +32,7 @@ fi
current_branch=$(git branch --show-current)
if [ -z "$current_branch" ]; then
echo "Not on a git branch, aborting..." >&2
echo "No git remote set, aborting..." >&2
exit 1
fi

View File

@@ -1,3 +0,0 @@
#!/bin/sh
echo "Rebooting Traefik on $KAMAL_HOSTS..."

View File

@@ -1,60 +1,53 @@
class Kamal::Cli::Traefik < Kamal::Cli::Base
desc "boot", "Boot Traefik on servers"
def boot
with_lock do
mutating do
on(KAMAL.traefik_hosts) do
execute *KAMAL.registry.login
execute *KAMAL.traefik.start_or_run
execute *KAMAL.traefik_static.ensure_config_directory
execute *KAMAL.traefik_static.start_or_run
end
end
end
desc "reboot", "Reboot Traefik on servers (stop container, remove container, start new container)"
option :rolling, type: :boolean, default: false, desc: "Reboot traefik on hosts in sequence, rather than in parallel"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def reboot
confirming "This will cause a brief outage on each host. Are you sure?" do
with_lock do
host_groups = options[:rolling] ? KAMAL.traefik_hosts : [ KAMAL.traefik_hosts ]
host_groups.each do |hosts|
host_list = Array(hosts).join(",")
run_hook "pre-traefik-reboot", hosts: host_list
on(hosts) do
execute *KAMAL.auditor.record("Rebooted traefik"), verbosity: :debug
execute *KAMAL.registry.login
execute *KAMAL.traefik.stop, raise_on_non_zero_exit: false
execute *KAMAL.traefik.remove_container
execute *KAMAL.traefik.run
end
run_hook "post-traefik-reboot", hosts: host_list
end
mutating do
on(KAMAL.traefik_hosts, in: options[:rolling] ? :sequence : :parallel) do
execute *KAMAL.auditor.record("Rebooted traefik"), verbosity: :debug
execute *KAMAL.registry.login
execute *KAMAL.traefik_static.stop
execute *KAMAL.traefik_static.remove_container
execute *KAMAL.traefik_static.ensure_config_directory
execute *KAMAL.traefik_static.run
end
end
end
desc "start", "Start existing Traefik container on servers"
def start
with_lock do
mutating do
on(KAMAL.traefik_hosts) do
execute *KAMAL.auditor.record("Started traefik"), verbosity: :debug
execute *KAMAL.traefik.start
execute *KAMAL.traefik_static.start
end
end
end
desc "stop", "Stop existing Traefik container on servers"
def stop
with_lock do
mutating do
on(KAMAL.traefik_hosts) do
execute *KAMAL.auditor.record("Stopped traefik"), verbosity: :debug
execute *KAMAL.traefik.stop, raise_on_non_zero_exit: false
execute *KAMAL.traefik_static.stop
end
end
end
desc "restart", "Restart existing Traefik container on servers"
def restart
with_lock do
mutating do
stop
start
end
@@ -62,7 +55,7 @@ class Kamal::Cli::Traefik < Kamal::Cli::Base
desc "details", "Show details about Traefik container from servers"
def details
on(KAMAL.traefik_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.traefik.info), type: "Traefik" }
on(KAMAL.traefik_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.traefik_static.info), type: "Traefik" }
end
desc "logs", "Show log lines from Traefik on servers"
@@ -76,22 +69,22 @@ class Kamal::Cli::Traefik < Kamal::Cli::Base
if options[:follow]
run_locally do
info "Following logs on #{KAMAL.primary_host}..."
info KAMAL.traefik.follow_logs(host: KAMAL.primary_host, grep: grep)
exec KAMAL.traefik.follow_logs(host: KAMAL.primary_host, grep: grep)
info KAMAL.traefik_static.follow_logs(host: KAMAL.primary_host, grep: grep)
exec KAMAL.traefik_static.follow_logs(host: KAMAL.primary_host, grep: grep)
end
else
since = options[:since]
lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
on(KAMAL.traefik_hosts) do |host|
puts_by_host host, capture(*KAMAL.traefik.logs(since: since, lines: lines, grep: grep)), type: "Traefik"
puts_by_host host, capture(*KAMAL.traefik_static.logs(since: since, lines: lines, grep: grep)), type: "Traefik"
end
end
end
desc "remove", "Remove Traefik container and image from servers"
def remove
with_lock do
mutating do
stop
remove_container
remove_image
@@ -100,20 +93,20 @@ class Kamal::Cli::Traefik < Kamal::Cli::Base
desc "remove_container", "Remove Traefik container from servers", hide: true
def remove_container
with_lock do
mutating do
on(KAMAL.traefik_hosts) do
execute *KAMAL.auditor.record("Removed traefik container"), verbosity: :debug
execute *KAMAL.traefik.remove_container
execute *KAMAL.traefik_static.remove_container
end
end
end
desc "remove_image", "Remove Traefik image from servers", hide: true
def remove_image
with_lock do
mutating do
on(KAMAL.traefik_hosts) do
execute *KAMAL.auditor.record("Removed traefik image"), verbosity: :debug
execute *KAMAL.traefik.remove_image
execute *KAMAL.traefik_static.remove_image
end
end
end

View File

@@ -2,14 +2,12 @@ require "active_support/core_ext/enumerable"
require "active_support/core_ext/module/delegation"
class Kamal::Commander
attr_accessor :verbosity, :holding_lock, :connected
delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :traefik_hosts, :accessory_hosts, to: :specifics
attr_accessor :verbosity, :holding_lock, :hold_lock_on_error
def initialize
self.verbosity = :info
self.holding_lock = false
self.connected = false
@specifics = nil
self.hold_lock_on_error = false
end
def config
@@ -26,36 +24,53 @@ class Kamal::Commander
attr_reader :specific_roles, :specific_hosts
def specific_primary!
@specifics = nil
self.specific_hosts = [ config.primary_host ]
self.specific_hosts = [ config.primary_web_host ]
end
def specific_roles=(role_names)
@specifics = nil
if role_names.present?
@specific_roles = Kamal::Utils.filter_specific_items(role_names, config.roles)
if @specific_roles.empty?
raise ArgumentError, "No --roles match for #{role_names.join(',')}"
end
@specific_roles
end
@specific_roles = config.roles.select { |r| role_names.include?(r.name) } if role_names.present?
end
def specific_hosts=(hosts)
@specifics = nil
if hosts.present?
@specific_hosts = Kamal::Utils.filter_specific_items(hosts, config.all_hosts)
@specific_hosts = config.all_hosts & hosts if hosts.present?
end
if @specific_hosts.empty?
raise ArgumentError, "No --hosts match for #{hosts.join(',')}"
end
def primary_host
specific_hosts&.first || specific_roles&.first&.primary_host || config.primary_web_host
end
@specific_hosts
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 boot_strategy
if config.boot.limit.present?
{ in: :groups, limit: config.boot.limit, wait: config.boot.wait }
else
{}
end
end
def roles_on(host)
roles.select { |role| role.hosts.include?(host.to_s) }.map(&:name)
end
def traefik_hosts
specific_hosts || config.traefik_hosts
end
def accessory_hosts
specific_hosts || config.accessories.flat_map(&:hosts)
end
def accessory_names
config.accessories&.collect(&:name) || []
end
@@ -65,8 +80,8 @@ class Kamal::Commander
end
def app(role: nil, host: nil)
Kamal::Commands::App.new(config, role: role, host: host)
def app(role: nil)
Kamal::Commands::App.new(config, role: role || config.roles.first.name)
end
def accessory(name)
@@ -109,10 +124,13 @@ class Kamal::Commander
@server ||= Kamal::Commands::Server.new(config)
end
def traefik
@traefik ||= Kamal::Commands::Traefik.new(config)
def traefik_static
@traefik_static ||= Kamal::Commands::Traefik::Static.new(config)
end
def traefik_dynamic(role: nil)
Kamal::Commands::Traefik::Dynamic.new(config, role: role || config.roles.first.name)
end
def with_verbosity(level)
old_level = self.verbosity
@@ -126,20 +144,12 @@ class Kamal::Commander
SSHKit.config.output_verbosity = old_level
end
def boot_strategy
if config.boot.limit.present?
{ in: :groups, limit: config.boot.limit, wait: config.boot.wait }
else
{}
end
end
def holding_lock?
self.holding_lock
end
def connected?
self.connected
def hold_lock_on_error?
self.hold_lock_on_error
end
private
@@ -153,8 +163,4 @@ class Kamal::Commander
SSHKit.config.command_map[:docker] = "docker" # No need to use /usr/bin/env, just clogs up the logs
SSHKit.config.output_verbosity = verbosity
end
def specifics
@specifics ||= Kamal::Commander::Specifics.new(config, specific_hosts, specific_roles)
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 traefik_hosts
config.traefik_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

@@ -86,6 +86,10 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
end
end
def make_directory_for(remote_file)
make_directory Pathname.new(remote_file).dirname.to_s
end
def remove_service_directory
[ :rm, "-rf", service_name ]
end
@@ -99,11 +103,11 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
end
def make_env_directory
make_directory accessory_config.env.secrets_directory
make_directory accessory_config.host_env_directory
end
def remove_env_file
[ :rm, "-f", accessory_config.env.secrets_file ]
[:rm, "-f", accessory_config.host_env_file_path]
end
private

View File

@@ -1,14 +1,15 @@
class Kamal::Commands::App < Kamal::Commands::Base
include Assets, Containers, Cord, Execution, Images, Logging
ACTIVE_DOCKER_STATUSES = [ :running, :restarting ]
attr_reader :role, :host
attr_reader :role
def initialize(config, role: nil, host: nil)
def initialize(config, role: nil)
super(config)
@role = role
@host = host
end
def start_or_run(hostname: nil)
combine start, run(hostname: hostname), by: "||"
end
def run(hostname: nil)
@@ -16,18 +17,16 @@ class Kamal::Commands::App < Kamal::Commands::Base
"--detach",
"--restart unless-stopped",
"--name", container_name,
*([ "--hostname", hostname ] if hostname),
*(["--hostname", hostname] if hostname),
"-e", "KAMAL_CONTAINER_NAME=\"#{container_name}\"",
"-e", "KAMAL_VERSION=\"#{config.version}\"",
*role.env_args(host),
*role.health_check_args,
*role.logging_args,
*role_config.env_args,
*role_config.health_check_args,
*config.logging_args,
*config.volume_args,
*role.asset_volume_args,
*role.label_args,
*role.option_args,
*role_config.label_args,
*role_config.option_args,
config.absolute_image,
role.cmd
role_config.cmd
end
def start
@@ -48,9 +47,58 @@ class Kamal::Commands::App < Kamal::Commands::Base
docker :ps, *filter_args
end
def ip_address(version:)
docker :inspect, "-f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}'", container_name(version)
end
def logs(since: nil, lines: nil, grep: nil)
pipe \
current_running_container_id,
"xargs docker logs#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1",
("grep '#{grep}'" if grep)
end
def follow_logs(host:, grep: nil)
run_over_ssh \
pipe(
current_running_container_id,
"xargs docker logs --timestamps --tail 10 --follow 2>&1",
(%(grep "#{grep}") if grep)
),
host: host
end
def execute_in_existing_container(*command, interactive: false)
docker :exec,
("-it" if interactive),
container_name,
*command
end
def execute_in_new_container(*command, interactive: false)
docker :run,
("-it" if interactive),
"--rm",
*role_config&.env_args,
*config.volume_args,
*role_config&.option_args,
config.absolute_image,
*command
end
def execute_in_existing_container_over_ssh(*command, host:)
run_over_ssh execute_in_existing_container(*command, interactive: true), host: host
end
def execute_in_new_container_over_ssh(*command, host:)
run_over_ssh execute_in_new_container(*command, interactive: true), host: host
end
def current_running_container_id
current_running_container(format: "--quiet")
docker :ps, "--quiet", *filter_args(statuses: ACTIVE_DOCKER_STATUSES), "--latest"
end
def container_id_for_version(version, only_running: false)
@@ -58,59 +106,70 @@ class Kamal::Commands::App < Kamal::Commands::Base
end
def current_running_version
pipe \
current_running_container(format: "--format '{{.Names}}'"),
extract_version_from_name
list_versions("--latest", statuses: ACTIVE_DOCKER_STATUSES)
end
def list_versions(*docker_args, statuses: nil)
pipe \
docker(:ps, *filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'),
extract_version_from_name
%(while read line; do echo ${line##{role_config.full_name}-}; done) # Extract SHA from "service-role-dest-SHA"
end
def list_containers
docker :container, :ls, "--all", *filter_args
end
def list_container_names
[ *list_containers, "--format", "'{{ .Names }}'" ]
end
def remove_container(version:)
pipe \
container_id_for(container_name: container_name(version)),
xargs(docker(:container, :rm))
end
def rename_container(version:, new_version:)
docker :rename, container_name(version), container_name(new_version)
end
def remove_containers
docker :container, :prune, "--force", *filter_args
end
def list_images
docker :image, :ls, config.repository
end
def remove_images
docker :image, :prune, "--all", "--force", *filter_args
end
def tag_current_as_latest
docker :tag, config.absolute_image, config.latest_image
end
def make_env_directory
make_directory role.env(host).secrets_directory
make_directory config.role(role).host_env_directory
end
def remove_env_file
[ :rm, "-f", role.env(host).secrets_file ]
[:rm, "-f", config.role(role).host_env_file_path]
end
def service_role_dest
[config.service, role, config.destination].compact.join("-")
end
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)
[ role_config.full_name, version || config.version ].compact.join("-")
end
def filter_args(statuses: nil)
argumentize "--filter", filters(statuses: statuses)
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)
[ "label=service=#{config.service}" ].tap do |filters|
filters << "label=destination=#{config.destination}" if config.destination
@@ -120,4 +179,8 @@ class Kamal::Commands::App < Kamal::Commands::Base
end
end
end
def role_config
@role_config ||= config.role(self.role)
end
end

View File

@@ -1,51 +0,0 @@
module Kamal::Commands::App::Assets
def extract_assets
asset_container = "#{role.container_prefix}-assets"
combine \
make_directory(role.asset_extracted_path),
[ *docker(:stop, "-t 1", asset_container, "2> /dev/null"), "|| true" ],
docker(:run, "--name", asset_container, "--detach", "--rm", config.absolute_image, "sleep 1000000"),
docker(:cp, "-L", "#{asset_container}:#{role.asset_path}/.", role.asset_extracted_path),
docker(:stop, "-t 1", asset_container),
by: "&&"
end
def sync_asset_volumes(old_version: nil)
new_extracted_path, new_volume_path = role.asset_extracted_path(config.version), role.asset_volume.host_path
if old_version.present?
old_extracted_path, old_volume_path = role.asset_extracted_path(old_version), role.asset_volume(old_version).host_path
end
commands = [ make_directory(new_volume_path), copy_contents(new_extracted_path, new_volume_path) ]
if old_version.present?
commands << copy_contents(new_extracted_path, old_volume_path, continue_on_error: true)
commands << copy_contents(old_extracted_path, new_volume_path, continue_on_error: true)
end
chain *commands
end
def clean_up_assets
chain \
find_and_remove_older_siblings(role.asset_extracted_path),
find_and_remove_older_siblings(role.asset_volume_path)
end
private
def find_and_remove_older_siblings(path)
[
:find,
Pathname.new(path).dirname.to_s,
"-maxdepth 1",
"-name", "'#{role.container_prefix}-*'",
"!", "-name", Pathname.new(path).basename.to_s,
"-exec rm -rf \"{}\" +"
]
end
def copy_contents(source, destination, continue_on_error: false)
[ :cp, "-rnT", "#{source}", destination, *("|| true" if continue_on_error) ]
end
end

View File

@@ -1,31 +0,0 @@
module Kamal::Commands::App::Containers
DOCKER_HEALTH_LOG_FORMAT = "'{{json .State.Health}}'"
def list_containers
docker :container, :ls, "--all", *filter_args
end
def list_container_names
[ *list_containers, "--format", "'{{ .Names }}'" ]
end
def remove_container(version:)
pipe \
container_id_for(container_name: container_name(version)),
xargs(docker(:container, :rm))
end
def rename_container(version:, new_version:)
docker :rename, container_name(version), container_name(new_version)
end
def remove_containers
docker :container, :prune, "--force", *filter_args
end
def container_health_log(version:)
pipe \
container_id_for(container_name: container_name(version)),
xargs(docker(:inspect, "--format", DOCKER_HEALTH_LOG_FORMAT))
end
end

View File

@@ -1,22 +0,0 @@
module Kamal::Commands::App::Cord
def cord(version:)
pipe \
docker(:inspect, "-f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}'", container_name(version)),
[ :awk, "'$2 == \"#{role.cord_volume.container_path}\" {print $1}'" ]
end
def tie_cord(cord)
create_empty_file(cord)
end
def cut_cord(cord)
remove_directory(cord)
end
private
def create_empty_file(file)
chain \
make_directory_for(file),
[ :touch, file ]
end
end

View File

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

View File

@@ -1,13 +0,0 @@
module Kamal::Commands::App::Images
def list_images
docker :image, :ls, config.repository
end
def remove_images
docker :image, :prune, "--all", "--force", *filter_args
end
def tag_latest_image
docker :tag, config.absolute_image, config.latest_image
end
end

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ module Kamal::Commands
delegate :sensitive, :argumentize, to: Kamal::Utils
DOCKER_HEALTH_STATUS_FORMAT = "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'"
DOCKER_HEALTH_LOG_FORMAT = "'{{json .State.Health}}'"
attr_accessor :config
@@ -17,7 +18,7 @@ module Kamal::Commands
elsif config.ssh.proxy && config.ssh.proxy.is_a?(Net::SSH::Proxy::Command)
cmd << " -o ProxyCommand='#{config.ssh.proxy.command_line_template}'"
end
cmd << " -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ").gsub("'", "'\\\\''")}'"
cmd << " -t #{config.ssh.user}@#{host} '#{command.join(" ")}'"
end
end
@@ -33,10 +34,6 @@ module Kamal::Commands
[ :mkdir, "-p", path ]
end
def remove_directory(path)
[ :rm, "-r", path ]
end
private
def combine(*commands, by: "&&")
commands
@@ -61,26 +58,14 @@ module Kamal::Commands
combine *commands, by: ">"
end
def any(*commands)
combine *commands, by: "||"
end
def xargs(command)
[ :xargs, command ].flatten
end
def shell(command)
[ :sh, "-c", "'#{command.flatten.join(" ").gsub("'", "'\\\\''")}'" ]
end
def docker(*args)
args.compact.unshift :docker
end
def git(*args, path: nil)
[ :git, *([ "-C", path ] if path), *args.compact ]
end
def tags(**details)
Kamal::Tags.from_config(config, **details)
end

View File

@@ -1,9 +1,7 @@
require "active_support/core_ext/string/filters"
class Kamal::Commands::Builder < Kamal::Commands::Base
delegate :create, :remove, :push, :clean, :pull, :info, :validate_image, to: :target
include Clone
delegate :create, :remove, :push, :clean, :pull, :info, to: :target
def name
target.class.to_s.remove("Kamal::Commands::Builder::").underscore.inquiry

View File

@@ -3,7 +3,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
class BuilderError < StandardError; end
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, to: :builder_config
def clean
docker :image, :rm, "--force", config.absolute_image
@@ -14,22 +14,13 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
end
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 ]
end
def build_context
config.builder.context
end
def validate_image
pipe \
docker(:inspect, "-f", "'{{ .Config.Labels.service }}'", config.absolute_image),
any(
[ :grep, "-x", config.service ],
"(echo \"Image #{config.absolute_image} is missing the 'service' label\" && exit 1)"
)
end
private
def build_tags
@@ -38,8 +29,8 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
def build_cache
if cache_to && cache_from
[ "--cache-to", cache_to,
"--cache-from", cache_from ]
["--cache-to", cache_to,
"--cache-from", cache_from]
end
end
@@ -63,14 +54,6 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
end
end
def build_target
argumentize "--target", target if target.present?
end
def build_ssh
argumentize "--ssh", ssh if ssh.present?
end
def builder_config
config.builder
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

@@ -7,31 +7,23 @@ class Kamal::Commands::Builder::Multiarch < Kamal::Commands::Builder::Base
docker :buildx, :rm, builder_name
end
def push
docker :buildx, :build,
"--push",
"--platform", "linux/amd64,linux/arm64",
"--builder", builder_name,
*build_options,
build_context
end
def info
combine \
docker(:context, :ls),
docker(:buildx, :ls)
end
def push
docker :buildx, :build,
"--push",
"--platform", platform_names,
"--builder", builder_name,
*build_options,
build_context
end
private
def builder_name
"kamal-#{config.service}-multiarch"
end
def platform_names
if local_arch
"linux/#{local_arch}"
else
"linux/amd64,linux/arm64"
end
end
end

View File

@@ -7,14 +7,14 @@ class Kamal::Commands::Builder::Native < Kamal::Commands::Builder::Base
# No-op on native without cache
end
def info
# No-op on native
end
def push
combine \
docker(:build, *build_options, build_context),
docker(:push, config.absolute_image),
docker(:push, config.latest_image)
end
def info
# No-op on native
end
end

View File

@@ -11,21 +11,21 @@ class Kamal::Commands::Builder::Native::Remote < Kamal::Commands::Builder::Nativ
remove_buildx
end
def push
docker :buildx, :build,
"--push",
"--platform", platform,
"--builder", builder_name,
*build_options,
build_context
end
def info
chain \
docker(:context, :ls),
docker(:buildx, :ls)
end
def push
docker :buildx, :build,
"--push",
"--platform", platform,
"--builder", builder_name,
*build_options,
build_context
end
private
def builder_name

View File

@@ -1,7 +1,7 @@
class Kamal::Commands::Docker < Kamal::Commands::Base
# Install Docker using the https://github.com/docker/docker-install convenience script.
def install
pipe get_docker, :sh
pipe [ :curl, "-fsSL", "https://get.docker.com" ], :sh
end
# Checks the Docker client version. Fails if Docker is not installed.
@@ -16,15 +16,6 @@ class Kamal::Commands::Docker < Kamal::Commands::Base
# Do we have superuser access to install Docker and start system services?
def superuser?
[ '[ "${EUID:-$(id -u)}" -eq 0 ] || command -v sudo >/dev/null || command -v su >/dev/null' ]
[ '[ "${EUID:-$(id -u)}" -eq 0 ]' ]
end
private
def get_docker
shell \
any \
[ :curl, "-fsSL", "https://get.docker.com" ],
[ :wget, "-O -", "https://get.docker.com" ],
[ :echo, "\"exit 1\"" ]
end
end

View File

@@ -0,0 +1,57 @@
class Kamal::Commands::Healthcheck < Kamal::Commands::Base
EXPOSED_PORT = 3999
def run
web = config.role(:web)
docker :run,
"--detach",
"--name", container_name_with_version,
"--publish", "#{EXPOSED_PORT}:#{config.healthcheck["port"]}",
"--label", "service=#{container_name}",
"-e", "KAMAL_CONTAINER_NAME=\"#{container_name}\"",
*web.env_args,
*web.health_check_args,
*config.volume_args,
*web.option_args,
config.absolute_image,
web.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", 50, "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
[ "healthcheck", config.service, config.destination ].compact.join("-")
end
def container_name_with_version
"#{container_name}-#{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
end

View File

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

View File

@@ -1,18 +1,17 @@
require "active_support/duration"
require "time"
require "base64"
class Kamal::Commands::Lock < Kamal::Commands::Base
def acquire(message, version)
combine \
[ :mkdir, lock_dir ],
[:mkdir, lock_dir],
write_lock_details(message, version)
end
def release
combine \
[ :rm, lock_details_file ],
[ :rm, "-r", lock_dir ]
[:rm, lock_details_file],
[:rm, "-r", lock_dir]
end
def status
@@ -21,41 +20,31 @@ class Kamal::Commands::Lock < Kamal::Commands::Base
read_lock_details
end
def ensure_locks_directory
[ :mkdir, "-p", locks_dir ]
end
private
def write_lock_details(message, version)
write \
[ :echo, "\"#{Base64.encode64(lock_details(message, version))}\"" ],
[:echo, "\"#{Base64.encode64(lock_details(message, version))}\""],
lock_details_file
end
def read_lock_details
pipe \
[ :cat, lock_details_file ],
[ :base64, "-d" ]
[:cat, lock_details_file],
[:base64, "-d"]
end
def stat_lock_dir
write \
[ :stat, lock_dir ],
[:stat, lock_dir],
"/dev/null"
end
def locks_dir
File.join(config.run_directory, "locks")
end
def lock_dir
dir_name = [ config.service, config.destination ].compact.join("-")
File.join(locks_dir, dir_name)
"#{config.run_directory}/lock-#{config.service}"
end
def lock_details_file
File.join(lock_dir, "details")
[lock_dir, :details].join("/")
end
def lock_details(message, version)
@@ -67,7 +56,7 @@ class Kamal::Commands::Lock < Kamal::Commands::Base
end
def locked_by
Kamal::Git.user_name
`git config user.name`.strip
rescue Errno::ENOENT
"Unknown"
end

View File

@@ -3,7 +3,7 @@ require "active_support/core_ext/numeric/time"
class Kamal::Commands::Prune < Kamal::Commands::Base
def dangling_images
docker :image, :prune, "--force", "--filter", "label=service=#{config.service}"
docker :image, :prune, "--force", "--filter", "label=service=#{config.service}", "--filter", "dangling=true"
end
def tagged_images
@@ -13,20 +13,16 @@ class Kamal::Commands::Prune < Kamal::Commands::Base
"while read image tag; do docker rmi $tag; done"
end
def app_containers(retain:)
def containers(keep_last: 5)
pipe \
docker(:ps, "-q", "-a", *service_filter, *stopped_containers_filters),
"tail -n +#{retain + 1}",
"tail -n +#{keep_last + 1}",
"while read container_id; do docker rm $container_id; done"
end
def healthcheck_containers
docker :container, :prune, "--force", *healthcheck_service_filter
end
private
def stopped_containers_filters
[ "created", "exited", "dead" ].flat_map { |status| [ "--filter", "status=#{status}" ] }
[ "created", "exited", "dead" ].flat_map { |status| ["--filter", "status=#{status}"] }
end
def active_image_list
@@ -39,8 +35,4 @@ class Kamal::Commands::Prune < Kamal::Commands::Base
def service_filter
[ "--filter", "label=service=#{config.service}" ]
end
def healthcheck_service_filter
[ "--filter", "label=service=#{config.healthcheck_service}" ]
end
end

View File

@@ -2,10 +2,7 @@ class Kamal::Commands::Registry < Kamal::Commands::Base
delegate :registry, to: :config
def login
docker :login,
registry["server"],
"-u", sensitive(Kamal::Utils.escape_shell_value(lookup("username"))),
"-p", sensitive(Kamal::Utils.escape_shell_value(lookup("password")))
docker :login, registry["server"], "-u", sensitive(lookup("username")), "-p", sensitive(lookup("password"))
end
def logout

View File

@@ -1,5 +1,5 @@
class Kamal::Commands::Server < Kamal::Commands::Base
def ensure_run_directory
[ :mkdir, "-p", config.run_directory ]
make_directory config.run_directory
end
end

View File

@@ -1,124 +0,0 @@
class Kamal::Commands::Traefik < Kamal::Commands::Base
delegate :argumentize, :optionize, to: Kamal::Utils
DEFAULT_IMAGE = "traefik:v2.10"
CONTAINER_PORT = 80
DEFAULT_ARGS = {
"log.level" => "DEBUG"
}
DEFAULT_LABELS = {
# These ensure we serve a 502 rather than a 404 if no containers are available
"traefik.http.routers.catchall.entryPoints" => "http",
"traefik.http.routers.catchall.rule" => "PathPrefix(`/`)",
"traefik.http.routers.catchall.service" => "unavailable",
"traefik.http.routers.catchall.priority" => 1,
"traefik.http.services.unavailable.loadbalancer.server.port" => "0"
}
def run
docker :run, "--name traefik",
"--detach",
"--restart", "unless-stopped",
*publish_args,
"--volume", "/var/run/docker.sock:/var/run/docker.sock",
*env_args,
*config.logging_args,
*label_args,
*docker_options_args,
image,
"--providers.docker",
*cmd_option_args
end
def start
docker :container, :start, "traefik"
end
def stop
docker :container, :stop, "traefik"
end
def start_or_run
any start, run
end
def info
docker :ps, "--filter", "name=^traefik$"
end
def logs(since: nil, lines: nil, grep: nil)
pipe \
docker(:logs, "traefik", (" --since #{since}" if since), (" --tail #{lines}" if lines), "--timestamps", "2>&1"),
("grep '#{grep}'" if grep)
end
def follow_logs(host:, grep: nil)
run_over_ssh pipe(
docker(:logs, "traefik", "--timestamps", "--tail", "10", "--follow", "2>&1"),
(%(grep "#{grep}") if grep)
).join(" "), host: host
end
def remove_container
docker :container, :prune, "--force", "--filter", "label=org.opencontainers.image.title=Traefik"
end
def remove_image
docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik"
end
def port
"#{host_port}:#{CONTAINER_PORT}"
end
def env
Kamal::Configuration::Env.from_config \
config: config.traefik.fetch("env", {}),
secrets_file: File.join(config.host_env_directory, "traefik", "traefik.env")
end
def make_env_directory
make_directory(env.secrets_directory)
end
def remove_env_file
[ :rm, "-f", env.secrets_file ]
end
private
def publish_args
argumentize "--publish", port unless config.traefik["publish"] == false
end
def label_args
argumentize "--label", labels
end
def env_args
env.args
end
def labels
DEFAULT_LABELS.merge(config.traefik["labels"] || {})
end
def image
config.traefik.fetch("image") { DEFAULT_IMAGE }
end
def docker_options_args
optionize(config.traefik["options"] || {})
end
def cmd_option_args
if args = config.traefik["args"]
optionize DEFAULT_ARGS.merge(args), with: "="
else
optionize DEFAULT_ARGS, with: "="
end
end
def host_port
config.traefik["host_port"] || CONTAINER_PORT
end
end

View File

@@ -0,0 +1,43 @@
class Kamal::Commands::Traefik::Dynamic < Kamal::Commands::Base
attr_reader :static_config, :dynamic_config
def initialize(config, role: nil)
super(config)
@static_config = Kamal::Configuration::Traefik::Static.new(config: config)
@dynamic_config = Kamal::Configuration::Traefik::Dynamic.new(config: config, role: role)
end
def run_id
pipe \
[:docker, :exec, :traefik, :wget, "-qSO", "/dev/null", "http://localhost:#{Kamal::Configuration::Traefik::Static::CONTAINER_PORT}#{config.healthcheck["path"]}", "2>&1"],
[:grep, "-i", Kamal::Configuration::Traefik::Dynamic::RUN_ID_HEADER],
[:cut, "-d ' ' -f 4"]
end
def write_config(ip_address:)
# Write to tmp then mv for an atomic copy. If you write directly traefik sees an empty file
# and removes the service before picking up the new config.
temp_config_file = "/tmp/kamal-traefik-config-#{rand(10000000)}"
chain \
write([:echo, dynamic_config.config(ip_address: ip_address).to_yaml.shellescape], temp_config_file),
[:mv, temp_config_file, host_file]
end
def remove_config
[:rm, host_file]
end
def boot_check?
dynamic_config.boot_check?
end
def config_run_id
dynamic_config.run_id
end
private
def host_file
"#{static_config.host_directory}/#{dynamic_config.host_file}"
end
end

View File

@@ -0,0 +1,70 @@
class Kamal::Commands::Traefik::Static < Kamal::Commands::Base
attr_reader :static_config, :dynamic_config
def initialize(config, role: nil)
super(config)
@static_config = Kamal::Configuration::Traefik::Static.new(config: config)
end
def run
docker :run, static_config.docker_args, static_config.image, static_config.traefik_args
end
def start
docker :container, :start, "traefik"
end
def stop
docker :container, :stop, "traefik"
end
def start_or_run
combine start, run, by: "||"
end
def info
docker :ps, "--filter", "name=^traefik$"
end
def logs(since: nil, lines: nil, grep: nil)
pipe \
docker(:logs, "traefik", (" --since #{since}" if since), (" --tail #{lines}" if lines), "--timestamps", "2>&1"),
("grep '#{grep}'" if grep)
end
def follow_logs(host:, grep: nil)
run_over_ssh pipe(
docker(:logs, "traefik", "--timestamps", "--tail", "10", "--follow", "2>&1"),
(%(grep "#{grep}") if grep)
).join(" "), host: host
end
def remove_container
docker :container, :prune, "--force", "--filter", "label=org.opencontainers.image.title=Traefik"
end
def remove_image
docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik"
end
def port
"#{host_port}:#{CONTAINER_PORT}"
end
def make_env_directory
make_directory(static_config.host_env_directory)
end
def remove_env_file
[:rm, "-f", static_config.host_env_file_path]
end
def ensure_config_directory
make_directory(static_config.host_directory)
end
def docker_entrypoint_args
docker :inspect, "-f '{{index .Args 1 }}'", :traefik
end
end

View File

@@ -6,10 +6,11 @@ require "erb"
require "net/ssh/proxy/jump"
class Kamal::Configuration
delegate :service, :image, :servers, :labels, :registry, :stop_wait_time, :hooks_path, :logging, to: :raw_config, allow_nil: true
delegate :argumentize, :optionize, to: Kamal::Utils
delegate :service, :image, :servers, :env, :labels, :registry, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true
delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Kamal::Utils
attr_reader :destination, :raw_config
attr_accessor :destination
attr_accessor :raw_config
class << self
def create_from(config_file:, destination: nil, version: nil)
@@ -25,9 +26,7 @@ class Kamal::Configuration
def load_config_file(file)
if file.exist?
# Newer Psych doesn't load aliases by default
load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load
YAML.send(load_method, ERB.new(IO.read(file)).result).symbolize_keys
YAML.load(ERB.new(IO.read(file)).result).symbolize_keys
else
raise "Configuration file not found in #{file}"
end
@@ -55,18 +54,11 @@ class Kamal::Configuration
end
def abbreviated_version
if version
# Don't abbreviate <sha>_uncommitted_<etc>
if version.include?("_")
version
else
version[0...7]
end
end
Kamal::Utils.abbreviate_version(version)
end
def minimum_version
raw_config.minimum_version
def run_directory
raw_config.run_directory || ".kamal"
end
@@ -88,37 +80,22 @@ class Kamal::Configuration
def all_hosts
(roles + accessories).flat_map(&:hosts).uniq
roles.flat_map(&:hosts).uniq
end
def primary_host
primary_role&.primary_host
end
def primary_role_name
raw_config.primary_role || "web"
end
def primary_role
role(primary_role_name)
end
def allow_empty_roles?
raw_config.allow_empty_roles
end
def traefik_roles
roles.select(&:running_traefik?)
end
def traefik_role_names
traefik_roles.flat_map(&:name)
def primary_web_host
role(:web).primary_host
end
def traefik_hosts
traefik_roles.flat_map(&:hosts).uniq
roles.select(&:running_traefik?).flat_map(&:hosts).uniq
end
def boot
Kamal::Configuration::Boot.new(config: self)
end
def repository
[ raw_config.registry["server"], image ].compact.join("/")
end
@@ -128,25 +105,13 @@ class Kamal::Configuration
end
def latest_image
"#{repository}:#{latest_tag}"
end
def latest_tag
[ "latest", *destination ].join("-")
"#{repository}:latest"
end
def service_with_version
"#{service}-#{version}"
end
def require_destination?
raw_config.require_destination
end
def retain_containers
raw_config.retain_containers || 5
end
def volume_args
if raw_config.volumes.present?
@@ -157,27 +122,15 @@ class Kamal::Configuration
end
def logging_args
if logging.present?
optionize({ "log-driver" => logging["driver"] }.compact) +
argumentize("--log-opt", logging["options"])
if raw_config.logging.present?
optionize({ "log-driver" => raw_config.logging["driver"] }.compact) +
argumentize("--log-opt", raw_config.logging["options"])
else
argumentize("--log-opt", { "max-size" => "10m" })
end
end
def boot
Kamal::Configuration::Boot.new(config: self)
end
def builder
Kamal::Configuration::Builder.new(config: self)
end
def traefik
raw_config.traefik || {}
end
def ssh
Kamal::Configuration::Ssh.new(config: self)
end
@@ -188,73 +141,27 @@ class Kamal::Configuration
def healthcheck
{ "path" => "/up", "port" => 3000, "max_attempts" => 7, "cord" => "/tmp/kamal-cord", "log_lines" => 50 }.merge(raw_config.healthcheck || {})
end
def healthcheck_service
[ "healthcheck", service, destination ].compact.join("-")
{ "path" => "/up", "port" => 3000, "max_attempts" => 7 }.merge(raw_config.healthcheck || {})
end
def readiness_delay
raw_config.readiness_delay || 7
end
def run_id
@run_id ||= SecureRandom.hex(16)
def minimum_version
raw_config.minimum_version
end
def run_directory
raw_config.run_directory || ".kamal"
end
def run_directory_as_docker_volume
if Pathname.new(run_directory).absolute?
run_directory
else
File.join "$(pwd)", run_directory
end
end
def hooks_path
raw_config.hooks_path || ".kamal/hooks"
end
def asset_path
raw_config.asset_path
end
def host_env_directory
File.join(run_directory, "env")
end
def env
raw_config.env || {}
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?
ensure_destination_if_required && ensure_required_keys_present && ensure_valid_kamal_version && ensure_retain_containers_valid && ensure_valid_service_name
ensure_required_keys_present && ensure_valid_kamal_version && ensure_no_traefik_labels
end
def to_h
{
roles: role_names,
hosts: all_hosts,
primary_host: primary_host,
primary_host: primary_web_host,
version: version,
repository: repository,
absolute_image: absolute_image,
@@ -269,17 +176,31 @@ class Kamal::Configuration
}.compact
end
def traefik
raw_config.traefik || {}
end
def hooks_path
raw_config.hooks_path || ".kamal/hooks"
end
def builder
Kamal::Configuration::Builder.new(config: self)
end
# Will raise KeyError if any secret ENVs are missing
def ensure_env_available
roles.each(&:env_file)
true
end
def host_env_directory
"#{run_directory}/env"
end
private
# Will raise ArgumentError if any required config keys are missing
def ensure_destination_if_required
if require_destination? && destination.nil?
raise ArgumentError, "You must specify a destination"
end
true
end
def ensure_required_keys_present
%i[ service image registry servers ].each do |key|
raise ArgumentError, "Missing required configuration for #{key}" unless raw_config[key].present?
@@ -293,31 +214,15 @@ class Kamal::Configuration
raise ArgumentError, "You must specify a password for the registry in config/deploy.yml (or set the ENV variable if that's used)"
end
unless role_names.include?(primary_role_name)
raise ArgumentError, "The primary_role #{primary_role_name} isn't defined"
end
if primary_role.hosts.empty?
raise ArgumentError, "No servers specified for the #{primary_role.name} primary_role"
end
unless allow_empty_roles?
roles.each do |role|
if role.hosts.empty?
raise ArgumentError, "No servers specified for the #{role.name} role. You can ignore this with allow_empty_roles: true"
end
roles.each do |role|
if role.hosts.empty?
raise ArgumentError, "No servers specified for the #{role.name} role"
end
end
true
end
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
true
end
def ensure_valid_kamal_version
if minimum_version && Gem::Version.new(minimum_version) > Gem::Version.new(Kamal::VERSION)
raise ArgumentError, "Current version is #{Kamal::VERSION}, minimum required is #{minimum_version}"
@@ -326,8 +231,13 @@ class Kamal::Configuration
true
end
def ensure_retain_containers_valid
raise ArgumentError, "Must retain at least 1 container" if retain_containers < 1
def ensure_no_traefik_labels
# The switch to a traefik file provider means that traefik labels on app containers are ignored
# We'll raise an error and suggest moving them
if roles.any? { |role| role.labels.keys.any? { |label| label.start_with?("traefik.") } }
raise ArgumentError, "Traefik is not configured to read labels, move traefik config to dynamic:"
end
true
end
@@ -339,11 +249,10 @@ class Kamal::Configuration
def git_version
@git_version ||=
if Kamal::Git.used?
if Kamal::Git.uncommitted_changes.present? && !builder.git_clone?
uncommitted_suffix = "_uncommitted_#{SecureRandom.hex(8)}"
end
[ Kamal::Git.revision, uncommitted_suffix ].compact.join
if system("git rev-parse")
uncommitted_suffix = Kamal::Utils.uncommitted_changes.present? ? "_uncommitted_#{SecureRandom.hex(8)}" : ""
"#{`git rev-parse HEAD`.strip}#{uncommitted_suffix}"
else
raise "Can't use commit hash as version, no git repository found in #{Dir.pwd}"
end

View File

@@ -1,5 +1,5 @@
class Kamal::Configuration::Accessory
delegate :argumentize, :optionize, to: Kamal::Utils
delegate :argumentize, :env_file_with_secrets, :optionize, to: Kamal::Utils
attr_accessor :name, :specifics
@@ -8,7 +8,7 @@ class Kamal::Configuration::Accessory
end
def service_name
specifics["service"] || "#{config.service}-#{name}"
"#{config.service}-#{name}"
end
def image
@@ -16,7 +16,7 @@ class Kamal::Configuration::Accessory
end
def hosts
if (specifics.keys & [ "host", "hosts", "roles" ]).size != 1
if (specifics.keys & ["host", "hosts", "roles"]).size != 1
raise ArgumentError, "Specify one of `host`, `hosts` or `roles` for accessory `#{name}`"
end
@@ -42,13 +42,23 @@ class Kamal::Configuration::Accessory
end
def env
Kamal::Configuration::Env.from_config \
config: specifics.fetch("env", {}),
secrets_file: File.join(config.host_env_directory, "accessories", "#{service_name}.env")
specifics["env"] || {}
end
def env_file
env_file_with_secrets env
end
def host_env_directory
File.join config.host_env_directory, "accessories"
end
def host_env_file_path
File.join host_env_directory, "#{service_name}.env"
end
def env_args
env.args
argumentize "--env-file", host_env_file_path
end
def files
@@ -60,8 +70,8 @@ class Kamal::Configuration::Accessory
def directories
specifics["directories"]&.to_h do |host_to_container_mapping|
host_path, container_path = host_to_container_mapping.split(":")
[ expand_host_path(host_path), container_path ]
host_relative_path, container_path = host_to_container_mapping.split(":")
[ expand_host_path(host_relative_path), container_path ]
end || {}
end
@@ -101,10 +111,10 @@ class Kamal::Configuration::Accessory
end
def with_clear_env_loaded
env.clear.each { |k, v| ENV[k] = v }
(env["clear"] || env).each { |k, v| ENV[k] = v }
yield
ensure
env.clear.each { |k, v| ENV.delete(k) }
(env["clear"] || env).each { |k, v| ENV.delete(k) }
end
def read_dynamic_file(local_file)
@@ -128,17 +138,13 @@ class Kamal::Configuration::Accessory
def remote_directories_as_volumes
specifics["directories"]&.collect do |host_to_container_mapping|
host_path, container_path = host_to_container_mapping.split(":")
[ expand_host_path(host_path), container_path ].join(":")
host_relative_path, container_path = host_to_container_mapping.split(":")
[ expand_host_path(host_relative_path), container_path ].join(":")
end || []
end
def expand_host_path(host_path)
absolute_path?(host_path) ? host_path : File.join(service_data_directory, host_path)
end
def absolute_path?(path)
Pathname.new(path).absolute?
def expand_host_path(host_relative_path)
"#{service_data_directory}/#{host_relative_path}"
end
def service_data_directory
@@ -149,7 +155,7 @@ class Kamal::Configuration::Accessory
if specifics.key?("host")
host = specifics["host"]
if host
[ host ]
[host]
else
raise ArgumentError, "Missing host for accessory `#{name}`"
end

View File

@@ -8,7 +8,7 @@ class Kamal::Configuration::Boot
limit = @options["limit"]
if limit.to_s.end_with?("%")
[ @host_count * limit.to_i / 100, 1 ].max
@host_count * limit.to_i / 100
else
limit
end

View File

@@ -3,8 +3,6 @@ class Kamal::Configuration::Builder
@options = config.raw_config.builder || {}
@image = config.image
@server = config.registry["server"]
@service = config.service
@destination = config.destination
valid?
end
@@ -41,10 +39,6 @@ class Kamal::Configuration::Builder
@options["dockerfile"] || "Dockerfile"
end
def target
@options["target"]
end
def context
@options["context"] || "."
end
@@ -87,31 +81,10 @@ class Kamal::Configuration::Builder
end
end
def ssh
@options["ssh"]
end
def git_clone?
Kamal::Git.used? && @options["context"].nil?
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
def valid?
if @options["cache"] && @options["cache"]["type"]
raise ArgumentError, "Invalid cache type: #{@options["cache"]["type"]}" unless [ "gha", "registry" ].include?(@options["cache"]["type"])
raise ArgumentError, "Invalid cache type: #{@options["cache"]["type"]}" unless ["gha", "registry"].include?(@options["cache"]["type"])
end
end
@@ -132,22 +105,10 @@ class Kamal::Configuration::Builder
end
def cache_to_config_for_gha
[ "type=gha", @options["cache"]&.fetch("options", nil) ].compact.join(",")
[ "type=gha", @options["cache"]&.fetch("options", nil)].compact.join(",")
end
def cache_to_config_for_registry
[ "type=registry", @options["cache"]&.fetch("options", nil), "ref=#{cache_image_ref}" ].compact.join(",")
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

View File

@@ -1,40 +0,0 @@
class Kamal::Configuration::Env
attr_reader :secrets_keys, :clear, :secrets_file
delegate :argumentize, to: Kamal::Utils
def self.from_config(config:, secrets_file: nil)
secrets_keys = config.fetch("secret", [])
clear = config.fetch("clear", config.key?("secret") || config.key?("tags") ? {} : config)
new clear: clear, secrets_keys: secrets_keys, secrets_file: secrets_file
end
def initialize(clear:, secrets_keys:, secrets_file:)
@clear = clear
@secrets_keys = secrets_keys
@secrets_file = secrets_file
end
def args
[ "--env-file", secrets_file, *argumentize("--env", clear) ]
end
def secrets_io
StringIO.new(Kamal::EnvFile.new(secrets).to_s)
end
def secrets
@secrets ||= secrets_keys.to_h { |key| [ key, ENV.fetch(key) ] }
end
def secrets_directory
File.dirname(secrets_file)
end
def merge(other)
self.class.new \
clear: @clear.merge(other.clear),
secrets_keys: @secrets_keys | other.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,13 +1,10 @@
class Kamal::Configuration::Role
CORD_FILE = "cord"
delegate :argumentize, :optionize, to: Kamal::Utils
delegate :argumentize, :env_file_with_secrets, :optionize, to: Kamal::Utils
attr_accessor :name
alias to_s name
def initialize(name, config:)
@name, @config = name.inquiry, config
@tagged_hosts ||= extract_tagged_hosts_from_config
end
def primary_host
@@ -15,11 +12,61 @@ class Kamal::Configuration::Role
end
def hosts
tagged_hosts.keys
@hosts ||= extract_hosts_from_config
end
def env_tags(host)
tagged_hosts.fetch(host).collect { |tag| config.env_tag(tag) }
def labels
default_labels.merge(custom_labels)
end
def label_args
argumentize "--label", labels
end
def env
if config.env && config.env["secret"]
merged_env_with_secrets
else
merged_env
end
end
def env_file
env_file_with_secrets env
end
def host_env_directory
File.join config.host_env_directory, "roles"
end
def host_env_file_path
File.join host_env_directory, "#{[config.service, name, config.destination].compact.join("-")}.env"
end
def env_args
argumentize "--env-file", host_env_file_path
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
options = specializations["healthcheck"] || {}
options = config.healthcheck.merge(options) if running_traefik?
options["cmd"] || http_health_check(port: options["port"], path: options["path"])
end
def health_check_interval
options = specializations["healthcheck"] || {}
options = config.healthcheck.merge(options) if running_traefik?
options["interval"] || "1s"
end
def cmd
@@ -34,160 +81,26 @@ class Kamal::Configuration::Role
end
end
def labels
default_labels.merge(traefik_labels).merge(custom_labels)
end
def label_args
argumentize "--label", labels
end
def logging_args
args = config.logging || {}
args.deep_merge!(specializations["logging"]) if specializations["logging"].present?
if args.any?
optionize({ "log-driver" => args["driver"] }.compact) +
argumentize("--log-opt", args["options"])
else
config.logging_args
end
end
def env(host)
@envs ||= {}
@envs[host] ||= [ base_env, specialized_env, *env_tags(host).map(&:env) ].reduce(:merge)
end
def env_args(host)
env(host).args
end
def asset_volume_args
asset_volume&.docker_args
end
def health_check_args(cord: true)
if health_check_cmd.present?
if cord && uses_cord?
optionize({ "health-cmd" => health_check_cmd_with_cord, "health-interval" => health_check_interval })
.concat(cord_volume.docker_args)
else
optionize({ "health-cmd" => health_check_cmd, "health-interval" => health_check_interval })
end
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_cmd_with_cord
"(#{health_check_cmd}) && (stat #{cord_container_file} > /dev/null || exit 1)"
end
def health_check_interval
health_check_options["interval"] || "1s"
end
def running_traefik?
if specializations["traefik"].nil?
primary?
name.web? || (specializations["traefik"] != nil && specializations["traefik"] != false)
end
def traefik
case specializations["traefik"]
when NilClass, TrueClass, FalseClass
{}
else
specializations["traefik"]
end
end
def primary?
self == @config.primary_role
end
def uses_cord?
running_traefik? && cord_volume && health_check_cmd.present?
end
def cord_host_directory
File.join config.run_directory_as_docker_volume, "cords", [ container_prefix, config.run_id ].join("-")
end
def cord_volume
if (cord = health_check_options["cord"])
@cord_volume ||= Kamal::Configuration::Volume.new \
host_path: File.join(config.run_directory, "cords", [ container_prefix, config.run_id ].join("-")),
container_path: cord
end
end
def cord_host_file
File.join cord_volume.host_path, CORD_FILE
end
def cord_container_directory
health_check_options.fetch("cord", nil)
end
def cord_container_file
File.join cord_volume.container_path, CORD_FILE
end
def container_name(version = nil)
[ container_prefix, version || config.version ].compact.join("-")
end
def container_prefix
def full_name
[ config.service, name, config.destination ].compact.join("-")
end
def asset_path
specializations["asset_path"] || config.asset_path
end
def assets?
asset_path.present? && running_traefik?
end
def asset_volume(version = nil)
if assets?
Kamal::Configuration::Volume.new \
host_path: asset_volume_path(version), container_path: asset_path
end
end
def asset_extracted_path(version = nil)
File.join config.run_directory, "assets", "extracted", container_name(version)
end
def asset_volume_path(version = nil)
File.join config.run_directory, "assets", "volumes", container_name(version)
end
private
attr_accessor :config, :tagged_hosts
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
attr_accessor :config
def extract_hosts_from_config
if config.servers.is_a?(Array)
@@ -199,30 +112,13 @@ class Kamal::Configuration::Role
end
def default_labels
{ "service" => config.service, "role" => name, "destination" => config.destination }
end
def traefik_labels
if running_traefik?
{
# Setting a service property ensures that the generated service name will be consistent between versions
"traefik.http.services.#{traefik_service}.loadbalancer.server.scheme" => "http",
"traefik.http.routers.#{traefik_service}.rule" => "PathPrefix(`/`)",
"traefik.http.routers.#{traefik_service}.priority" => "2",
"traefik.http.middlewares.#{traefik_service}-retry.retry.attempts" => "5",
"traefik.http.middlewares.#{traefik_service}-retry.retry.initialinterval" => "500ms",
"traefik.http.routers.#{traefik_service}.middlewares" => "#{traefik_service}-retry@docker"
}
if config.destination
{ "service" => config.service, "role" => name, "destination" => config.destination }
else
{}
{ "service" => config.service, "role" => name }
end
end
def traefik_service
container_prefix
end
def custom_labels
Hash.new.tap do |labels|
labels.merge!(config.labels) if config.labels.present?
@@ -232,32 +128,34 @@ class Kamal::Configuration::Role
def specializations
if config.servers.is_a?(Array) || config.servers[name].is_a?(Array)
{}
{ }
else
config.servers[name].except("hosts")
end
end
def specialized_env
Kamal::Configuration::Env.from_config config: specializations.fetch("env", {})
specializations["env"] || {}
end
def merged_env
config.env&.merge(specialized_env) || {}
end
# Secrets are stored in an array, which won't merge by default, so have to do it by hand.
def base_env
Kamal::Configuration::Env.from_config \
config: config.env,
secrets_file: File.join(config.host_env_directory, "roles", "#{container_prefix}.env")
def merged_env_with_secrets
merged_env.tap do |new_env|
new_env["secret"] = Array(config.env["secret"]) + Array(specialized_env["secret"])
# If there's no secret/clear split, everything is clear
clear_app_env = config.env["secret"] ? Array(config.env["clear"]) : Array(config.env["clear"] || config.env)
clear_role_env = specialized_env["secret"] ? Array(specialized_env["clear"]) : Array(specialized_env["clear"] || specialized_env)
new_env["clear"] = (clear_app_env + clear_role_env).uniq
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_traefik?
options
end
end
end

View File

@@ -9,10 +9,6 @@ class Kamal::Configuration::Ssh
config.fetch("user", "root")
end
def port
config.fetch("port", 22)
end
def proxy
if (proxy = config["proxy"])
Net::SSH::Proxy::Jump.new(proxy.include?("@") ? proxy : "root@#{proxy}")
@@ -22,7 +18,7 @@ class Kamal::Configuration::Ssh
end
def options
{ user: user, port: port, proxy: proxy, logger: logger, keepalive: true, keepalive_interval: 30 }.compact
{ user: user, proxy: proxy, auth_methods: [ "publickey" ], logger: logger, keepalive: true, keepalive_interval: 30 }.compact
end
def to_h

View File

@@ -0,0 +1,66 @@
class Kamal::Configuration::Traefik::Dynamic
RUN_ID_HEADER = "X-Kamal-Run-ID"
delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Kamal::Utils
attr_reader :traefik_config, :role_config, :role_traefik_config
def initialize(config:, role:)
@traefik_config = config.traefik || {}
@role_config = config.role(role)
@role_traefik_config = role_config&.traefik || {}
end
def host_file
"#{role_config.full_name}.yml"
end
def config(ip_address:)
default_config(ip_address:).deep_merge!(custom_config)
end
def boot_check?
role_traefik_config.fetch("boot_check") { traefik_config.fetch("boot_check", true) }
end
def run_id
@run_id ||= SecureRandom.hex(16)
end
private
def default_config(ip_address:)
run_id_header_middleware = "#{role_config.full_name}-id-header"
{
"http" => {
"routers" => {
role_config.full_name => {
"rule" => "PathPrefix(`/`)",
"middlewares" => [ run_id_header_middleware ],
"service" => role_config.full_name
}
},
"services" => {
role_config.full_name => {
"loadbalancer" => {
"servers" => [ { "url" => "http://#{ip_address}:80" } ]
}
}
},
"middlewares" => {
run_id_header_middleware => {
"headers" => {
"customresponseheaders" => {
RUN_ID_HEADER => run_id
}
}
}
}
}
}
end
def custom_config
traefik_config.fetch("dynamic", {}).deep_merge(role_traefik_config.fetch("dynamic", {}))
end
end

View File

@@ -0,0 +1,84 @@
class Kamal::Configuration::Traefik::Static
CONTAINER_PORT = 80
DEFAULT_IMAGE = "traefik:v2.9"
CONFIG_DIRECTORY = "/var/run/traefik-config"
DEFAULT_ARGS = {
"providers.docker": true, # Obsolete now but required for zero-downtime upgrade from previous versions
"providers.file.directory" => "/var/run/traefik-config",
"providers.file.watch": true,
"log.level" => "DEBUG",
}
delegate :argumentize, :env_file_with_secrets, :optionize, to: Kamal::Utils
attr_reader :config, :traefik_config
def initialize(config:)
@config = config
@traefik_config = config.traefik || {}
end
def docker_args
[
"--name traefik",
"--detach",
"--restart", "unless-stopped",
*publish_args,
"--volume", "/var/run/docker.sock:/var/run/docker.sock",
"--volume", "#{host_directory}:#{CONFIG_DIRECTORY}",
*env_args,
*config.logging_args,
*label_args,
*docker_options_args
]
end
def image
traefik_config.fetch("image") { DEFAULT_IMAGE }
end
def traefik_args
optionize DEFAULT_ARGS.merge(traefik_config.fetch("args", {})), with: "="
end
def host_directory
if Pathname.new(config.run_directory).absolute?
"#{config.run_directory}/traefik-config"
else
"$(pwd)/#{config.run_directory}/traefik-config"
end
end
def host_env_file_path
File.join host_env_directory, "traefik.env"
end
def host_env_directory
File.join config.host_env_directory, "traefik"
end
def env_file
env_file_with_secrets config.traefik.fetch("env", {})
end
private
def host_port
traefik_config.fetch("host_port", CONTAINER_PORT)
end
def publish_args
argumentize "--publish", "#{host_port}:#{CONTAINER_PORT}" unless traefik_config["publish"] == false
end
def env_args
argumentize "--env-file", host_env_file_path
end
def label_args
argumentize "--label", traefik_config.fetch("labels", [])
end
def docker_options_args
optionize(traefik_config["options"] || {})
end
end

View File

@@ -1,22 +0,0 @@
class Kamal::Configuration::Volume
attr_reader :host_path, :container_path
delegate :argumentize, to: Kamal::Utils
def initialize(host_path:, container_path:)
@host_path = host_path
@container_path = container_path
end
def docker_args
argumentize "--volume", "#{host_path_for_docker_volume}:#{container_path}"
end
private
def host_path_for_docker_volume
if Pathname.new(host_path).absolute?
host_path
else
File.join "$(pwd)", host_path
end
end
end

View File

@@ -1,38 +0,0 @@
# Encode an env hash as a string where secret values have been looked up and all values escaped for Docker.
class Kamal::EnvFile
def initialize(env)
@env = env
end
def to_s
env_file = StringIO.new.tap do |contents|
@env.each do |key, value|
contents << docker_env_file_line(key, value)
end
end.string
# Ensure the file has some contents to avoid the SSHKIT empty file warning
env_file.presence || "\n"
end
alias to_str to_s
private
def docker_env_file_line(key, value)
"#{key}=#{escape_docker_env_file_value(value)}\n"
end
# Escape a value to make it safe to dump in a docker file.
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
# so remove leading and trailing ones and unescape any others
value.to_s.dump[1..-2].gsub(/\\"/, "\"")
end
end

View File

@@ -1,23 +0,0 @@
module Kamal::Git
extend self
def used?
system("git rev-parse")
end
def user_name
`git config user.name`.strip
end
def revision
`git rev-parse HEAD`.strip
end
def uncommitted_changes
`git status --porcelain`.strip
end
def root
`git rev-parse --show-toplevel`.strip
end
end

View File

@@ -1,6 +1,5 @@
require "sshkit"
require "sshkit/dsl"
require "net/scp"
require "active_support/core_ext/hash/deep_merge"
require "json"
@@ -103,39 +102,3 @@ class SSHKit::Backend::Netssh
prepend LimitConcurrentStartsInstance
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

@@ -9,13 +9,33 @@ module Kamal::Utils
if value.present?
attr = "#{key}=#{escape_shell_value(value)}"
attr = self.sensitive(attr, redaction: "#{key}=[REDACTED]") if sensitive
[ argument, attr ]
[ argument, attr]
else
[ argument, key ]
end
end
end
def env_file_with_secrets(env)
env_file = StringIO.new.tap do |contents|
if (secrets = env["secret"]).present?
env.fetch("secret", env)&.each do |key|
contents << docker_env_file_line(key, ENV.fetch(key))
end
env["clear"]&.each do |key, value|
contents << docker_env_file_line(key, value)
end
else
env.fetch("clear", env)&.each do |key, value|
contents << docker_env_file_line(key, value)
end
end
end.string
# Ensure the file has some contents to avoid the SSHKIT empty file warning
env_file || "\n"
end
# Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option.
def optionize(args, with: nil)
options = if with
@@ -29,7 +49,7 @@ module Kamal::Utils
# Flattens a one-to-many structure into an array of two-element arrays each containing a key-value pair
def flatten_args(args)
args.flat_map { |key, value| value.try(:map) { |entry| [ key, entry ] } || [ [ key, value ] ] }
args.flat_map { |key, value| value.try(:map) { |entry| [key, entry] } || [ [ key, value ] ] }
end
# Marks sensitive values for redaction in logs and human-visible output.
@@ -52,6 +72,19 @@ module Kamal::Utils
end
end
def unredacted(value)
case
when value.respond_to?(:unredacted)
value.unredacted
when value.respond_to?(:transform_values)
value.transform_values { |value| unredacted value }
when value.respond_to?(:map)
value.map { |element| unredacted element }
else
value
end
end
# Escape a value to make it safe for shell use.
def escape_shell_value(value)
value.to_s.dump
@@ -59,22 +92,48 @@ module Kamal::Utils
.gsub(DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX, '\$')
end
# Apply a list of host or role filters, including wildcard matches
def filter_specific_items(filters, items)
matches = []
Array(filters).select do |filter|
matches += Array(items).select do |item|
# Only allow * for a wildcard
# items are roles or hosts
File.fnmatch(filter, item.to_s, File::FNM_EXTGLOB)
# Abbreviate a git revhash for concise display
def abbreviate_version(version)
if version
# Don't abbreviate <sha>_uncommitted_<etc>
if version.include?("_")
version
else
version[0...7]
end
end
matches.uniq
end
def stable_sort!(elements, &block)
elements.sort_by!.with_index { |element, index| [ block.call(element), index ] }
def uncommitted_changes
`git status --porcelain`.strip
end
def docker_env_file_line(key, value)
if key.include?("\n") || value.to_s.include?("\n")
raise ArgumentError, "docker env file format does not support newlines in keys or values, key: #{key}"
end
"#{key.to_s}=#{value.to_s}\n"
end
def poll(max_attempts:, exception:, &block)
attempt = 1
begin
block.call
rescue exception => 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
end
def info(message)
SSHKit.config.output.info(message)
end
end

View File

@@ -0,0 +1,22 @@
class Kamal::Utils::HealthcheckPoller
TRAEFIK_HEALTHY_DELAY = 2
class HealthcheckError < StandardError; end
class << self
def wait_for_healthy(pause_after_ready: false, &block)
Kamal::Utils.poll(max_attempts: KAMAL.config.healthcheck["max_attempts"], exception: HealthcheckError) do
case status = block.call
when "healthy"
sleep TRAEFIK_HEALTHY_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
SSHKit.config.output.info "Container is healthy!"
end
end
end
end

View File

@@ -1,5 +1,4 @@
require "active_support/core_ext/module/delegation"
require "sshkit"
class Kamal::Utils::Sensitive
# So SSHKit knows to redact these values.

View File

@@ -0,0 +1,17 @@
class Kamal::Utils::SwitchPoller
class SwitchError < StandardError; end
class << self
TRAEFIK_SWITCH_DELAY = 2
def wait_for_switch(traefik_dynamic, &block)
if traefik_dynamic.boot_check?
Kamal::Utils.poll(max_attempts: 5, exception: SwitchError) do
polled_run_id = block.call
raise SwitchError, "Waiting for #{traefik_dynamic.config_run_id}, currently #{polled_run_id}" unless polled_run_id == traefik_dynamic.config_run_id
end
else
sleep TRAEFIK_SWITCH_DELAY
end
end
end
end

View File

@@ -1,3 +1,3 @@
module Kamal
VERSION = "1.6.0"
VERSION = "0.16.1"
end

View File

@@ -7,7 +7,7 @@ class CliAccessoryTest < CliTestCase
run_command("boot", "mysql").tap do |output|
assert_match /docker login.*on 1.1.1.3/, output
assert_match "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 --env-file .kamal/env/accessories/app-mysql.env --env MYSQL_ROOT_HOST=\"%\" --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", output
assert_match "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 --env-file .kamal/env/accessories/app-mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", output
end
end
@@ -21,7 +21,7 @@ class CliAccessoryTest < CliTestCase
assert_match /docker login.*on 1.1.1.3/, output
assert_match /docker login.*on 1.1.1.1/, output
assert_match /docker login.*on 1.1.1.2/, output
assert_match "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 --env-file .kamal/env/accessories/app-mysql.env --env MYSQL_ROOT_HOST=\"%\" --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", output
assert_match "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 --env-file .kamal/env/accessories/app-mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", output
assert_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output
assert_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output
end
@@ -48,18 +48,6 @@ class CliAccessoryTest < CliTestCase
run_command("reboot", "mysql")
end
test "reboot all" do
Kamal::Commands::Registry.any_instance.expects(:login).times(3)
Kamal::Cli::Accessory.any_instance.expects(:stop).with("mysql")
Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("mysql")
Kamal::Cli::Accessory.any_instance.expects(:boot).with("mysql", login: false)
Kamal::Cli::Accessory.any_instance.expects(:stop).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:boot).with("redis", login: false)
run_command("reboot", "all")
end
test "start" do
assert_match "docker container start app-mysql", run_command("start", "mysql")
end
@@ -76,20 +64,11 @@ class CliAccessoryTest < CliTestCase
end
test "details" do
run_command("details", "mysql").tap do |output|
assert_match "docker ps --filter label=service=app-mysql", output
assert_match "Accessory mysql Host: 1.1.1.3", output
end
end
test "details with non-existent accessory" do
assert_equal "No accessory by the name of 'hello' (options: mysql and redis)", stderred { run_command("details", "hello") }
assert_match "docker ps --filter label=service=app-mysql", run_command("details", "mysql")
end
test "details with all" do
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-redis", output
end
@@ -118,7 +97,7 @@ class CliAccessoryTest < CliTestCase
test "logs with follow" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.3 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'")
.with("ssh -t root@1.1.1.3 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'")
assert_match "docker logs app-mysql --timestamps --tail 10 --follow 2>&1", run_command("logs", "mysql", "--follow")
end
@@ -157,32 +136,8 @@ class CliAccessoryTest < CliTestCase
assert_match "rm -rf app-mysql", run_command("remove_service_directory", "mysql")
end
test "hosts param respected" do
Kamal::Cli::Accessory.any_instance.expects(:directories).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis")
run_command("boot", "redis", "--hosts", "1.1.1.1").tap do |output|
assert_match /docker login.*on 1.1.1.1/, output
assert_no_match /docker login.*on 1.1.1.2/, output
assert_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output
assert_no_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output
end
end
test "hosts param intersected with configuration" do
Kamal::Cli::Accessory.any_instance.expects(:directories).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis")
run_command("boot", "redis", "--hosts", "1.1.1.1,1.1.1.3").tap do |output|
assert_match /docker login.*on 1.1.1.1/, output
assert_no_match /docker login.*on 1.1.1.3/, output
assert_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output
assert_no_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.3", output
end
end
private
def run_command(*command)
stdouted { Kamal::Cli::Accessory.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) }
stdouted { Kamal::Cli::Accessory.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

View File

@@ -5,47 +5,25 @@ class CliAppTest < CliTestCase
stub_running
run_command("boot").tap do |output|
assert_match "docker tag dhh/app:latest dhh/app:latest", 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 container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
end
end
test "boot will rename if same version is already running" do
Object.any_instance.stubs(:sleep)
run_command("details") # Preheat Kamal const
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(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("running") # health check
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, :inspect, "-f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}'", "app-web-123", "|", :awk, "'$2 == \"/tmp/kamal-cord\" {print $1}'", raise_on_non_zero_exit: false)
.returns("cordfile") # old 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", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("unhealthy") # old version unhealthy
stub_running
run_command("boot").tap do |output|
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 run --detach --restart unless-stopped --name app-web-latest --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
end
ensure
Thread.report_on_exception = true
end
test "boot uses group strategy when specified" do
Kamal::Cli::App.any_instance.stubs(:on).with("1.1.1.1").times(3) # ensure locks dir, acquire & release lock
Kamal::Cli::App.any_instance.stubs(:on).with("1.1.1.1").twice # acquire & release lock
Kamal::Cli::App.any_instance.stubs(:on).with([ "1.1.1.1" ]) # tag container
# Strategy is used when booting the containers
@@ -54,121 +32,44 @@ class CliAppTest < CliTestCase
run_command("boot", config: :with_boot_strategy)
end
test "boot errors don't leave lock in place" do
Kamal::Cli::App.any_instance.expects(:using_version).raises(RuntimeError)
assert_not KAMAL.holding_lock?
assert_raises(RuntimeError) do
stderred { run_command("boot") }
end
assert_not KAMAL.holding_lock?
end
test "boot with assets" do
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
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("running") # health check
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").twice # old version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :inspect, "-f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}'", "app-web-123", "|", :awk, "'$2 == \"/tmp/kamal-cord\" {print $1}'", raise_on_non_zero_exit: false)
.returns("") # old version
run_command("boot", config: :with_assets).tap do |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/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 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
end
end
test "boot with host tags" do
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
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("running") # health check
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, :inspect, "-f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}'", "app-web-123", "|", :awk, "'$2 == \"/tmp/kamal-cord\" {print $1}'", raise_on_non_zero_exit: false)
.returns("") # old version
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
Object.any_instance.stubs(:sleep)
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).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", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("running").at_least_once # web health check passing
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("unhealthy").at_least_once # web health check failing
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-workers-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("running").at_least_once # workers health check
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
test "boot without traefik file provider raises exception" do
Thread.report_on_exception = false
Object.any_instance.stubs(:sleep)
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).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", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("unhealthy").at_least_once # web health check failing
.with(:docker, :inspect, "-f '{{index .Args 1 }}'", :traefik)
.returns("[--providers.docker --log.level=DEBUG --accesslog --accesslog.format=json]").at_least_once
stderred do
run_command("boot", config: :with_roles, host: nil, allow_execute_error: true).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
assert_match "Running docker container ls --all --filter name=^app-web-latest$ --quiet | xargs docker stop on 1.1.1.1", output
assert_match "Running docker container ls --all --filter name=^app-web-latest$ --quiet | xargs docker stop on 1.1.1.2", output
end
assert_raises(SSHKit::Runner::ExecuteError, "Exception while executing on host 1.1.1.1: File provider not enabled, you'll need to run `kamal traefik reboot` to deploy") do
run_command("boot")
end
ensure
Thread.report_on_exception = true
end
test "boot errors leave lock in place" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999" }
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :inspect, "-f '{{index .Args 1 }}'", :traefik)
.returns("[--providers.docker --providers.file.directory=/var/run/traefik-config --providers.file.watch --log.level=DEBUG --accesslog --accesslog.format=json]").at_least_once
Kamal::Cli::App.any_instance.expects(:using_version).raises(RuntimeError)
assert !KAMAL.holding_lock?
assert_raises(RuntimeError) do
stderred { run_command("boot") }
end
assert KAMAL.holding_lock?
end
test "start" do
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info)
.with { |*args| args == [ :docker, :inspect, "-f '{{index .Args 1 }}'", :traefik ] }
.returns("[--providers.docker --providers.file.directory=/var/run/traefik-config --providers.file.watch --log.level=DEBUG --accesslog --accesslog.format=json]").at_least_once
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info)
.with { |*args| args != [ :docker, :inspect, "-f '{{index .Args 1 }}'", :traefik ] }
.returns("").at_least_once
run_command("start").tap do |output|
assert_match "docker start app-web-999", output
end
@@ -176,18 +77,14 @@ class CliAppTest < CliTestCase
test "stop" do
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
test "stale_containers" do
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)
.returns("12345678\n87654321\n")
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")
.returns("12345678\n87654321")
run_command("stale_containers").tap do |output|
assert_match /Detected stale container for role web with version 87654321/, output
@@ -197,11 +94,7 @@ class CliAppTest < CliTestCase
test "stop stale_containers" do
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)
.returns("12345678\n87654321\n")
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")
.returns("12345678\n87654321")
run_command("stale_containers", "--stop").tap do |output|
assert_match /Stopping stale container for role web with version 87654321/, output
@@ -217,7 +110,7 @@ class CliAppTest < CliTestCase
test "remove" do
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 image prune --all --force --filter label=service=app")}/, output
end
@@ -249,30 +142,11 @@ class CliAppTest < CliTestCase
test "exec with reuse" do
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
end
end
test "exec interactive" do
SSHKit::Backend::Abstract.any_instance.expects(:exec)
.with("ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --env-file .kamal/env/roles/app-web.env dhh/app:latest ruby -v'")
run_command("exec", "-i", "ruby -v").tap do |output|
assert_match "Get most recent version available as an image...", output
assert_match "Launching interactive command with version latest via SSH from new container on 1.1.1.1...", output
end
end
test "exec interactive with reuse" do
SSHKit::Backend::Abstract.any_instance.expects(:exec)
.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|
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 "Launching interactive command with version 999 via SSH from existing container on 1.1.1.1...", output
end
end
test "containers" do
run_command("containers").tap do |output|
assert_match "docker container ls --all --filter label=service=app", output
@@ -287,71 +161,57 @@ class CliAppTest < CliTestCase
test "logs" do
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
test "logs with follow" do
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 '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
test "version" do
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
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|
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
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
stdouted { Kamal::Cli::Main.start(["app", "version", "-c", "test/fixtures/deploy_with_accessories.yml", "--hosts", "1.1.1.1"]) }.tap do |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
private
def run_command(*command, config: :with_accessories, host: "1.1.1.1", allow_execute_error: false)
stdouted do
Kamal::Cli::App.start([ *command, "-c", "test/fixtures/deploy_#{config}.yml", *([ "--hosts", host ] if host) ])
rescue SSHKit::Runner::ExecuteError => e
raise e unless allow_execute_error
end
def run_command(*command, config: :with_accessories)
stdouted { Kamal::Cli::App.start([*command, "-c", "test/fixtures/deploy_#{config}.yml", "--hosts", "1.1.1.1"]) }
end
def stub_running
Object.any_instance.stubs(:sleep)
SecureRandom.stubs(:hex).with(16).returns("12345678901234567890123456789012")
SecureRandom.stubs(:hex).with(6).returns("123456789012")
SecureRandom.stubs(:hex).with(8).returns("1234567890123456")
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :inspect, "-f '{{index .Args 1 }}'", :traefik)
.returns("[--providers.docker --providers.file.directory=/var/run/traefik-config --providers.file.watch --log.level=DEBUG --accesslog --accesslog.format=json]").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", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("running") # health check
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("unhealthy") # health check
.with(:docker, :inspect, "-f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}'", "app-web-latest")
.returns("172.17.0.3").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :exec, :traefik, :wget, "-qSO", "/dev/null", "http://localhost:80/up", "2>&1", "|", :grep, "-i", "X-Kamal-Run-ID", "|", :cut, "-d ' ' -f 4")
.returns("12345678901234567890123456789012").at_least_once
end
end

View File

@@ -9,137 +9,29 @@ class CliBuildTest < CliTestCase
end
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)
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|
assert_no_match /Cloning repo into build directory/, output
run_command("push").tap do |output|
assert_hook_ran "pre-build", output, **hook_variables
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
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
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
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)
.with(:docker, "--version", "&&", :docker, :buildx, "version")
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args[0..1] == [:docker, :buildx] }
.raises(SSHKit::Command::Failed.new("no builder"))
.then
.returns(true)
SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :create, "--use", "--name", "kamal-app-multiarch")
SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with { |*args| args[0..1] == [ :docker, :buildx ] }
.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
run_command("push").tap do |output|
assert_match /Missing compatible builder, so creating a new one first/, output
end
end
@@ -156,17 +48,15 @@ class CliBuildTest < CliTestCase
test "push pre-build hook failure" do
fail_hook("pre-build")
error = assert_raises(Kamal::Cli::HookError) { run_command("push") }
assert_equal "Hook `pre-build` failed:\nfailed", error.message
assert_raises(Kamal::Cli::HookError) { run_command("push") }
assert @executions.none? { |args| args[0..2] == [ :docker, :buildx, :build ] }
assert @executions.none? { |args| args[0..2] == [:docker, :buildx, :build] }
end
test "pull" do
run_command("pull").tap do |output|
assert_match /docker image rm --force dhh\/app:999/, output
assert_match /docker pull dhh\/app:999/, output
assert_match "docker inspect -f '{{ .Config.Labels.service }}' dhh/app:999 | grep -x app || (echo \"Image dhh/app:999 is missing the 'service' label\" && exit 1)", output
end
end
@@ -176,22 +66,6 @@ class CliBuildTest < CliTestCase
end
end
test "create remote" do
run_command("create", fixture: :with_remote_builder).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'", output
assert_match "docker buildx create --name kamal-app-native-remote kamal-app-native-remote-amd64 --platform linux/amd64", output
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
stub_setup
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
@@ -221,27 +95,14 @@ class CliBuildTest < CliTestCase
end
private
def run_command(*command, fixture: :with_accessories)
stdouted { Kamal::Cli::Build.start([ *command, "-c", "test/fixtures/deploy_#{fixture}.yml" ]) }
def run_command(*command)
stdouted { Kamal::Cli::Build.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
def stub_dependency_checks
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, "--version", "&&", :docker, :buildx, "version")
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args[0..1] == [ :docker, :buildx ] }
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]
.with { |*args| args[0..1] == [:docker, :buildx] }
end
end

View File

@@ -21,7 +21,7 @@ class CliTestCase < ActiveSupport::TestCase
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| @executions << args; args != [ ".kamal/hooks/#{hook}" ] }
.with { |*args| @executions << args; args != [".kamal/hooks/#{hook}"] }
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args.first == ".kamal/hooks/#{hook}" }
.raises(SSHKit::Command::Failed.new("failed"))
@@ -31,14 +31,12 @@ class CliTestCase < ActiveSupport::TestCase
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args == [ :mkdir, "-p", ".kamal" ] }
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg1, arg2, arg3| arg1 == :mkdir && arg2 == "-p" && arg3 == ".kamal/locks" }
.with { |arg1, arg2| arg1 == :mkdir && arg2 == ".kamal/lock-app" }
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg1, arg2| arg1 == :mkdir && arg2 == ".kamal/locks/app" }
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg1, arg2| arg1 == :rm && arg2 == ".kamal/locks/app/details" }
.with { |arg1, arg2| arg1 == :rm && arg2 == ".kamal/lock-app/details" }
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
assert_match "Running the #{hook} hook...\n", output
@@ -52,7 +50,7 @@ class CliTestCase < ActiveSupport::TestCase
KAMAL_HOSTS=\"#{hosts}\"\s
KAMAL_COMMAND=\"#{command}\"\s
#{"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
assert_match expected, output

View File

@@ -13,6 +13,7 @@ class CliEnvTest < CliTestCase
assert_match ".kamal/env/roles/app-workers.env", output
assert_match ".kamal/env/traefik/traefik.env", output
assert_match ".kamal/env/accessories/app-redis.env", output
end
end
@@ -32,6 +33,6 @@ class CliEnvTest < CliTestCase
private
def run_command(*command)
stdouted { Kamal::Cli::Env.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) }
stdouted { Kamal::Cli::Env.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

View File

@@ -0,0 +1,75 @@
require_relative "cli_test_case"
class CliHealthcheckTest < CliTestCase
test "perform" do
# Prevent expected failures from outputting to terminal
Thread.report_on_exception = false
Object.any_instance.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)
# 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
ensure
Thread.report_on_exception = true
end
test "perform failing to become healthy" do
# Prevent expected failures from outputting to terminal
Thread.report_on_exception = false
Object.any_instance.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
ensure
Thread.report_on_exception = true
end
private
def run_command(*command)
stdouted { Kamal::Cli::Healthcheck.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

View File

@@ -3,7 +3,7 @@ require_relative "cli_test_case"
class CliLockTest < CliTestCase
test "status" do
run_command("status").tap do |output|
assert_match "Running /usr/bin/env stat .kamal/locks/app > /dev/null && cat .kamal/locks/app/details | base64 -d on 1.1.1.1", output
assert_match "Running /usr/bin/env stat .kamal/lock-app > /dev/null && cat .kamal/lock-app/details | base64 -d on 1.1.1.1", output
end
end
@@ -15,6 +15,6 @@ class CliLockTest < CliTestCase
private
def run_command(*command)
stdouted { Kamal::Cli::Lock.start([ *command, "-v", "-c", "test/fixtures/deploy_with_accessories.yml" ]) }
stdouted { Kamal::Cli::Lock.start([*command, "-v", "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

View File

@@ -2,69 +2,37 @@ require_relative "cli_test_case"
class CliMainTest < CliTestCase
test "setup" do
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:main:envify", [], 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:server:bootstrap")
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ])
Kamal::Cli::Main.any_instance.expects(:deploy)
run_command("setup").tap do |output|
assert_match /Ensure Docker is installed.../, output
assert_match /Evaluate and push env files.../, output
end
end
test "setup with skip_push" do
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:main:envify", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options)
# 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:build:pull", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], 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("setup", "--skip_push").tap do |output|
assert_match /Ensure Docker is installed.../, output
assert_match /Evaluate and push env files.../, output
# deploy
assert_match /Acquiring the deploy lock/, output
assert_match /Log into image registry/, output
assert_match /Pull app image/, output
assert_match /Ensure Traefik is running/, output
assert_match /Detect stale containers/, output
assert_match /Prune old containers and images/, output
assert_match /Releasing the deploy lock/, output
end
run_command("setup")
end
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:build:deliver", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], 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:healthcheck:perform", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], 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::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" }
run_command("deploy", "--verbose").tap do |output|
run_command("deploy").tap do |output|
assert_hook_ran "pre-connect", output, **hook_variables
assert_match /Log into image registry/, output
assert_match /Build and push app image/, output
assert_hook_ran "pre-deploy", output, **hook_variables
assert_match /Ensure Traefik is running/, output
assert_match /Ensure app can pass healthcheck/, output
assert_match /Detect stale containers/, 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
@@ -74,7 +42,8 @@ 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:build:pull", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], 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:healthcheck:perform", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], 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)
@@ -83,6 +52,7 @@ class CliMainTest < CliTestCase
assert_match /Log into image registry/, output
assert_match /Pull app image/, output
assert_match /Ensure Traefik is running/, output
assert_match /Ensure app can pass healthcheck/, output
assert_match /Detect stale containers/, output
assert_match /Prune old containers and images/, output
assert_match /Releasing the deploy lock/, output
@@ -92,62 +62,38 @@ class CliMainTest < CliTestCase
test "deploy when locked" do
Thread.report_on_exception = false
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
Dir.stubs(:chdir)
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args == [ :mkdir, "-p", ".kamal" ] }
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args == [ :mkdir, "-p", ".kamal/locks" ] }
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*arg| arg[0..1] == [ :mkdir, ".kamal/locks/app" ] }
.raises(RuntimeError, "mkdir: cannot create directory kamal/locks/app: File exists")
.with { |*args| args[0..1] == [:mkdir, ".kamal/lock-app"] }
.raises(RuntimeError, "mkdir: cannot create directory kamal_lock-app: File exists")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_debug)
.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("")
.with(:stat, ".kamal/lock-app", ">", "/dev/null", "&&", :cat, ".kamal/lock-app/details", "|", :base64, "-d")
assert_raises(Kamal::Cli::LockError) do
run_command("deploy")
end
ensure
Thread.report_on_exception = true
end
test "deploy error when locking" do
Thread.report_on_exception = false
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
Dir.stubs(:chdir)
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args == [ :mkdir, "-p", ".kamal" ] }
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args == [ :mkdir, "-p", ".kamal/locks" ] }
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*arg| arg[0..1] == [ :mkdir, ".kamal/locks/app" ] }
.with { |*arg| arg[0..1] == [:mkdir, ".kamal/lock-app"] }
.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
run_command("deploy")
end
ensure
Thread.report_on_exception = true
end
test "deploy errors during outside section leave remove lock" do
@@ -157,11 +103,11 @@ class CliMainTest < CliTestCase
.with("kamal:cli:registry:login", [], invoke_options)
.raises(RuntimeError)
assert_not KAMAL.holding_lock?
assert !KAMAL.holding_lock?
assert_raises(RuntimeError) do
stderred { run_command("deploy") }
end
assert_not KAMAL.holding_lock?
assert !KAMAL.holding_lock?
end
test "deploy with skipped hooks" do
@@ -170,60 +116,41 @@ 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:build:deliver", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], 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:healthcheck:perform", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], 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)
run_command("deploy", "--skip_hooks") do
assert_no_match /Running the post-deploy hook.../, output
refute_match /Running the post-deploy hook.../, output
end
end
test "deploy without healthcheck if primary host doesn't have traefik" do
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:build:deliver", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], 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_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:traefik:boot", [], 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")
assert_raises(KeyError) do
run_command("deploy", config_file: "deploy_with_secrets")
end
end
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:app:stale_containers", [], invoke_options.merge(stop: true))
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)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options)
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: "redeploy" }
run_command("redeploy", "--verbose").tap do |output|
run_command("redeploy").tap do |output|
assert_hook_ran "pre-connect", output, **hook_variables
assert_match /Build and push app image/, output
assert_hook_ran "pre-deploy", output, **hook_variables
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
@@ -231,11 +158,13 @@ class CliMainTest < CliTestCase
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:app:stale_containers", [], invoke_options.merge(stop: true))
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)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options)
run_command("redeploy", "--skip_push").tap do |output|
assert_match /Pull app image/, output
assert_match /Ensure app can pass healthcheck/, output
end
end
@@ -248,19 +177,23 @@ class CliMainTest < CliTestCase
assert_match /docker container ls --all --filter name=\^app-web-nonsense\$ --quiet/, output
assert_match /The app version 'nonsense' is not available as a container/, output
end
ensure
Thread.report_on_exception = true
end
test "rollback good version" do
Object.any_instance.stubs(:sleep)
SecureRandom.stubs(:hex).with(16).returns("12345678901234567890123456789012")
SecureRandom.stubs(:hex).with(6).returns("123456789012")
[ "web", "workers" ].each do |role|
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet", raise_on_non_zero_exit: false)
.with(:docker, :container, :ls, "--filter", "name=^app-#{role}-123$", "--quiet", raise_on_non_zero_exit: false)
.returns("").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet")
.returns("version-to-rollback\n").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=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
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
@@ -268,42 +201,60 @@ class CliMainTest < CliTestCase
end
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :inspect, "-f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}'", "app-web-version-to-rollback", "|", :awk, "'$2 == \"/tmp/kamal-cord\" {print $1}'", raise_on_non_zero_exit: false)
.returns("corddirectory").at_least_once # health check
.with(:docker, :inspect, "-f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}'", "app-web-123")
.returns("172.17.0.3").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-version-to-rollback$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("unhealthy").at_least_once # health check
.with(:docker, :inspect, "-f '{{index .Args 1 }}'", :traefik)
.returns("[--providers.docker --providers.file.directory=/var/run/traefik-config --providers.file.watch --log.level=DEBUG --accesslog --accesslog.format=json]").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :exec, :traefik, :wget, "-qSO", "/dev/null", "http://localhost:80/up", "2>&1", "|", :grep, "-i", "X-Kamal-Run-ID", "|", :cut, "-d ' ' -f 4")
.returns("12345678901234567890123456789012").at_least_once
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" }
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_match "Start container with version 123", output
assert_hook_ran "pre-deploy", output, **hook_variables
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 start app-web-123", output
assert_match "docker container ls --all --filter name=^app-web-version-to-rollback$ --quiet | xargs docker stop", output, "Should stop the container that was previously running"
assert_hook_ran "post-deploy", output, **hook_variables, runtime: true
assert_hook_ran "post-deploy", output, **hook_variables, runtime: "0"
end
end
test "rollback without old version" do
Kamal::Cli::Main.any_instance.stubs(:container_available?).returns(true)
Kamal::Cli::Healthcheck::Poller.stubs(:sleep)
Object.stubs(:sleep)
SecureRandom.stubs(:hex).with(16).returns("12345678901234567890123456789012")
SecureRandom.stubs(:hex).with(6).returns("123456789012")
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, :inspect, "-f '{{index .Args 1 }}'", :traefik)
.returns("[--providers.docker --providers.file.directory=/var/run/traefik-config --providers.file.watch --log.level=DEBUG --accesslog --accesslog.format=json]").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--filter", "name=^app-web-123$", "--quiet", raise_on_non_zero_exit: false)
.returns("").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=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
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("running").at_least_once # health check
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :inspect, "-f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}'", "app-web-123")
.returns("172.17.0.3").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :exec, :traefik, :wget, "-qSO", "/dev/null", "http://localhost:80/up", "2>&1", "|", :grep, "-i", "X-Kamal-Run-ID", "|", :cut, "-d ' ' -f 4")
.returns("12345678901234567890123456789012").at_least_once
run_command("rollback", "123").tap do |output|
assert_match "docker run --detach --restart unless-stopped --name app-web-123", output
assert_match "Start container with version 123", output
assert_match "docker start app-web-123 || docker run --detach --restart unless-stopped --name app-web-123", output
assert_no_match "docker stop", output
end
end
@@ -327,8 +278,8 @@ class CliMainTest < CliTestCase
run_command("config", config_file: "deploy_simple").tap do |output|
config = YAML.load(output)
assert_equal [ "web" ], config[:roles]
assert_equal [ "1.1.1.1", "1.1.1.2" ], config[:hosts]
assert_equal ["web"], config[:roles]
assert_equal ["1.1.1.1", "1.1.1.2"], config[:hosts]
assert_equal "999", config[:version]
assert_equal "dhh/app", config[:repository]
assert_equal "dhh/app:999", config[:absolute_image]
@@ -340,8 +291,8 @@ class CliMainTest < CliTestCase
run_command("config", config_file: "deploy_with_roles").tap do |output|
config = YAML.load(output)
assert_equal [ "web", "workers" ], config[:roles]
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], config[:hosts]
assert_equal ["web", "workers"], config[:roles]
assert_equal ["1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4"], config[:hosts]
assert_equal "999", config[:version]
assert_equal "registry.digitalocean.com/dhh/app", config[:repository]
assert_equal "registry.digitalocean.com/dhh/app:999", config[:absolute_image]
@@ -349,35 +300,12 @@ class CliMainTest < CliTestCase
end
end
test "config with primary web role override" do
run_command("config", config_file: "deploy_primary_web_role_override").tap do |output|
config = YAML.load(output)
assert_equal [ "web_chicago", "web_tokyo" ], config[:roles]
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], config[:hosts]
assert_equal "1.1.1.3", config[:primary_host]
end
end
test "config with destination" do
run_command("config", "-d", "world", config_file: "deploy_for_dest").tap do |output|
config = YAML.load(output)
assert_equal [ "web" ], config[:roles]
assert_equal [ "1.1.1.1", "1.1.1.2" ], config[:hosts]
assert_equal "999", config[:version]
assert_equal "registry.digitalocean.com/dhh/app", config[:repository]
assert_equal "registry.digitalocean.com/dhh/app:999", config[:absolute_image]
assert_equal "app-999", config[:service_with_version]
end
end
test "config with aliases" do
run_command("config", config_file: "deploy_with_aliases").tap do |output|
config = YAML.load(output)
assert_equal [ "web", "web_tokyo", "workers", "workers_tokyo" ], config[:roles]
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], config[:hosts]
assert_equal ["web"], config[:roles]
assert_equal ["1.1.1.1", "1.1.1.2"], config[:hosts]
assert_equal "999", config[:version]
assert_equal "registry.digitalocean.com/dhh/app", config[:repository]
assert_equal "registry.digitalocean.com/dhh/app:999", config[:absolute_image]
@@ -437,45 +365,19 @@ class CliMainTest < CliTestCase
end
test "envify" do
Pathname.any_instance.expects(:exist?).returns(true).times(3)
File.expects(:read).with(".env.erb").returns("HELLO=<%= 'world' %>")
File.expects(:write).with(".env", "HELLO=world", perm: 0600)
run_command("envify")
end
test "envify with blank line trimming" do
file = <<~EOF
HELLO=<%= 'world' %>
<% if true -%>
KEY=value
<% end -%>
EOF
Pathname.any_instance.expects(:exist?).returns(true).times(3)
File.expects(:read).with(".env.erb").returns(file.strip)
File.expects(:write).with(".env", "HELLO=world\nKEY=value\n", perm: 0600)
run_command("envify")
end
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(:write).with(".env.world", "HELLO=world", perm: 0600)
run_command("envify", "-d", "world", config_file: "deploy_for_dest")
end
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(:write).with(".env", "HELLO=world", perm: 0600)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:env:push").never
run_command("envify", "--skip-push")
end
test "remove with confirmation" do
run_command("remove", "-y", config_file: "deploy_with_accessories").tap do |output|
assert_match /docker container stop traefik/, output
@@ -507,6 +409,6 @@ class CliMainTest < CliTestCase
private
def run_command(*command, config_file: "deploy_simple")
stdouted { Kamal::Cli::Main.start([ *command, "-c", "test/fixtures/#{config_file}.yml" ]) }
stdouted { Kamal::Cli::Main.start([*command, "-c", "test/fixtures/#{config_file}.yml"]) }
end
end

View File

@@ -10,7 +10,7 @@ class CliPruneTest < CliTestCase
test "images" do
run_command("images").tap do |output|
assert_match "docker image prune --force --filter label=service=app on 1.1.1.", output
assert_match "docker image prune --force --filter label=service=app --filter dangling=true on 1.1.1.", output
assert_match "docker image ls --filter label=service=app --format '{{.ID}} {{.Repository}}:{{.Tag}}' | grep -v -w \"$(docker container ls -a --format '{{.Image}}\\|' --filter label=service=app | tr -d '\\n')dhh/app:latest\\|dhh/app:<none>\" | while read image tag; do docker rmi $tag; done on 1.1.1.", output
end
end
@@ -18,21 +18,11 @@ class CliPruneTest < CliTestCase
test "containers" do
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 container prune --force --filter label=service=healthcheck-app on 1.1.1.\d/, output
end
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 container prune --force --filter label=service=healthcheck-app on 1.1.1.\d/, output
end
assert_raises(RuntimeError, "retain must be at least 1") do
run_command("containers", "--retain", "0")
end
end
private
def run_command(*command)
stdouted { Kamal::Cli::Prune.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) }
stdouted { Kamal::Cli::Prune.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

View File

@@ -16,6 +16,6 @@ class CliRegistryTest < CliTestCase
private
def run_command(*command)
stdouted { Kamal::Cli::Registry.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) }
stdouted { Kamal::Cli::Registry.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

View File

@@ -1,32 +1,16 @@
require_relative "cli_test_case"
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
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(:mkdir, "-p", ".kamal").returns("").at_least_once
assert_equal "Acquiring the deploy lock...\nReleasing the deploy lock...", run_command("bootstrap")
assert_equal "", run_command("bootstrap")
end
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('[ "${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 ]', 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
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
@@ -35,25 +19,20 @@ class CliServerTest < CliTestCase
end
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('[ "${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('[ "${EUID:-$(id -u)}" -eq 0 ]', raise_on_non_zero_exit: false).returns(true).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:curl, "-fsSL", "https://get.docker.com", "|", :sh).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, "-p", ".kamal").returns("").at_least_once
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
run_command("bootstrap").tap do |output|
("1.1.1.1".."1.1.1.4").map do |host|
assert_match "Missing Docker on #{host}. Installing…", output
assert_match "Running the docker-setup hook", output
end
end
end
private
def run_command(*command)
stdouted { Kamal::Cli::Server.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) }
stdouted { Kamal::Cli::Server.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

View File

@@ -4,24 +4,24 @@ class CliTraefikTest < CliTestCase
test "boot" do
run_command("boot").tap do |output|
assert_match "docker login", output
assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" #{Kamal::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output
assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" #{Kamal::Configuration::Traefik::Static::DEFAULT_IMAGE} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\"", output
end
end
test "reboot" do
Kamal::Commands::Registry.any_instance.expects(:login).twice
run_command("reboot", "-y").tap do |output|
run_command("reboot").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 run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" #{Kamal::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output
assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" #{Kamal::Configuration::Traefik::Static::DEFAULT_IMAGE} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\"", output
end
end
test "reboot --rolling" do
Object.any_instance.stubs(:sleep)
run_command("reboot", "--rolling", "-y").tap do |output|
run_command("reboot", "--rolling").tap do |output|
assert_match "Running docker container prune --force --filter label=org.opencontainers.image.title=Traefik on 1.1.1.1", output
end
end
@@ -64,7 +64,7 @@ class CliTraefikTest < CliTestCase
test "logs with follow" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.1 -p 22 'docker logs traefik --timestamps --tail 10 --follow 2>&1'")
.with("ssh -t root@1.1.1.1 'docker logs traefik --timestamps --tail 10 --follow 2>&1'")
assert_match "docker logs traefik --timestamps --tail 10 --follow", run_command("logs", "--follow")
end
@@ -91,6 +91,6 @@ class CliTraefikTest < CliTestCase
private
def run_command(*command)
stdouted { Kamal::Cli::Traefik.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) }
stdouted { Kamal::Cli::Traefik.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

View File

@@ -14,23 +14,6 @@ class CommanderTest < ActiveSupport::TestCase
@kamal.specific_hosts = [ "1.1.1.1", "1.1.1.2" ]
assert_equal [ "1.1.1.1", "1.1.1.2" ], @kamal.hosts
@kamal.specific_hosts = [ "1.1.1.1*" ]
assert_equal [ "1.1.1.1" ], @kamal.hosts
@kamal.specific_hosts = [ "1.1.1.*", "*.1.2.*" ]
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.hosts
@kamal.specific_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
@kamal.specific_hosts = [ "*miss" ]
end
assert_match /hosts match for \*miss/, exception.message
end
test "filtering hosts by filtering roles" do
@@ -38,11 +21,6 @@ class CommanderTest < ActiveSupport::TestCase
@kamal.specific_roles = [ "web" ]
assert_equal [ "1.1.1.1", "1.1.1.2" ], @kamal.hosts
exception = assert_raises(ArgumentError) do
@kamal.specific_roles = [ "*miss" ]
end
assert_match /roles match for \*miss/, exception.message
end
test "filtering roles" do
@@ -50,23 +28,6 @@ class CommanderTest < ActiveSupport::TestCase
@kamal.specific_roles = [ "workers" ]
assert_equal [ "workers" ], @kamal.roles.map(&:name)
@kamal.specific_roles = [ "w*" ]
assert_equal [ "web", "workers" ], @kamal.roles.map(&:name)
@kamal.specific_roles = [ "we*", "*orkers" ]
assert_equal [ "web", "workers" ], @kamal.roles.map(&:name)
@kamal.specific_roles = [ "*" ]
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
@kamal.specific_roles = [ "*miss" ]
end
assert_match /roles match for \*miss/, exception.message
end
test "filtering roles by filtering hosts" do
@@ -88,20 +49,9 @@ class CommanderTest < ActiveSupport::TestCase
assert_equal "1.1.1.3", @kamal.primary_host
end
test "primary_role" do
assert_equal "web", @kamal.primary_role.name
@kamal.specific_roles = "workers"
assert_equal "workers", @kamal.primary_role.name
end
test "roles_on" do
assert_equal [ "web" ], @kamal.roles_on("1.1.1.1").map(&:name)
assert_equal [ "workers" ], @kamal.roles_on("1.1.1.3").map(&:name)
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)
assert_equal [ "web" ], @kamal.roles_on("1.1.1.1")
assert_equal [ "workers" ], @kamal.roles_on("1.1.1.3")
end
test "default group strategy" do
@@ -120,36 +70,6 @@ class CommanderTest < ActiveSupport::TestCase
assert_equal({ in: :groups, limit: 1, wait: 2 }, @kamal.boot_strategy)
end
test "percentage-based group strategy limit is at least 1" do
configure_with(:deploy_with_low_percentage_boot_strategy)
assert_equal({ in: :groups, limit: 1, wait: 2 }, @kamal.boot_strategy)
end
test "try to match the primary role from a list of specific roles" do
configure_with(:deploy_primary_web_role_override)
@kamal.specific_roles = [ "web_*" ]
assert_equal [ "web_tokyo", "web_chicago" ], @kamal.roles.map(&:name)
assert_equal "web_tokyo", @kamal.primary_role.name
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 "traefik 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.traefik_hosts
end
test "traefik 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.traefik_hosts
end
private
def configure_with(variant)
@kamal = Kamal::Commander.new.tap do |kamal|

View File

@@ -34,7 +34,6 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
]
},
"busybox" => {
"service" => "custom-busybox",
"image" => "busybox:latest",
"host" => "1.1.1.7"
}
@@ -50,15 +49,15 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "run" do
assert_equal \
"docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 --env-file .kamal/env/accessories/app-mysql.env --env MYSQL_ROOT_HOST=\"%\" --label service=\"app-mysql\" private.registry/mysql:8.0",
"docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 --env-file .kamal/env/accessories/app-mysql.env --label service=\"app-mysql\" private.registry/mysql:8.0",
new_command(:mysql).run.join(" ")
assert_equal \
"docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --env SOMETHING=\"else\" --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest",
"docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest",
new_command(:redis).run.join(" ")
assert_equal \
"docker run --name custom-busybox --detach --restart unless-stopped --log-opt max-size=\"10m\" --env-file .kamal/env/accessories/custom-busybox.env --label service=\"custom-busybox\" busybox:latest",
"docker run --name app-busybox --detach --restart unless-stopped --log-opt max-size=\"10m\" --env-file .kamal/env/accessories/app-busybox.env --label service=\"app-busybox\" busybox:latest",
new_command(:busybox).run.join(" ")
end
@@ -66,7 +65,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } }
assert_equal \
"docker run --name custom-busybox --detach --restart unless-stopped --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --env-file .kamal/env/accessories/custom-busybox.env --label service=\"custom-busybox\" busybox:latest",
"docker run --name app-busybox --detach --restart unless-stopped --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --env-file .kamal/env/accessories/app-busybox.env --label service=\"app-busybox\" busybox:latest",
new_command(:busybox).run.join(" ")
end
@@ -91,7 +90,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "execute in new container" do
assert_equal \
"docker run --rm --env-file .kamal/env/accessories/app-mysql.env --env MYSQL_ROOT_HOST=\"%\" private.registry/mysql:8.0 mysql -u root",
"docker run --rm --env-file .kamal/env/accessories/app-mysql.env private.registry/mysql:8.0 mysql -u root",
new_command(:mysql).execute_in_new_container("mysql", "-u", "root").join(" ")
end
@@ -103,14 +102,14 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "execute in new container over ssh" do
new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do
assert_match %r{docker run -it --rm --env-file .kamal/env/accessories/app-mysql.env --env MYSQL_ROOT_HOST=\"%\" private.registry/mysql:8.0 mysql -u root},
assert_match %r|docker run -it --rm --env-file .kamal/env/accessories/app-mysql.env private.registry/mysql:8.0 mysql -u root|,
new_command(:mysql).execute_in_new_container_over_ssh("mysql", "-u", "root")
end
end
test "execute in existing container over ssh" do
new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do
assert_match %r{docker exec -it app-mysql mysql -u root},
assert_match %r|docker exec -it app-mysql mysql -u root|,
new_command(:mysql).execute_in_existing_container_over_ssh("mysql", "-u", "root")
end
end
@@ -129,7 +128,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "follow logs" do
assert_equal \
"ssh -t root@1.1.1.5 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'",
"ssh -t root@1.1.1.5 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'",
new_command(:mysql).follow_logs
end

View File

@@ -3,7 +3,6 @@ require "test_helper"
class CommandsAppTest < ActiveSupport::TestCase
setup do
ENV["RAILS_MASTER_KEY"] = "456"
Kamal::Configuration.any_instance.stubs(:run_id).returns("12345678901234567890123456789012")
@config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], env: { "secret" => [ "RAILS_MASTER_KEY" ] } }
end
@@ -14,21 +13,21 @@ class CommandsAppTest < ActiveSupport::TestCase
test "run" do
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 --health-cmd \"(curl -f http://localhost:3000/up || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
"docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-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\" dhh/app:999",
new_command.run.join(" ")
end
test "run with hostname" do
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 --health-cmd \"(curl -f http://localhost:3000/up || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
"docker run --detach --restart unless-stopped --name app-web-999 --hostname myhost -e KAMAL_CONTAINER_NAME=\"app-web-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\" dhh/app:999",
new_command.run(hostname: "myhost").join(" ")
end
test "run with volumes" do
@config[:volumes] = [ "/local/path:/container/path" ]
@config[:volumes] = ["/local/path:/container/path" ]
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 --health-cmd \"(curl -f http://localhost:3000/up || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --volume /local/path:/container/path --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
"docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-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\" dhh/app:999",
new_command.run.join(" ")
end
@@ -36,7 +35,7 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:healthcheck] = { "path" => "/healthz" }
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 --health-cmd \"(curl -f http://localhost:3000/healthz || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
"docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-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\" dhh/app:999",
new_command.run.join(" ")
end
@@ -44,7 +43,7 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:healthcheck] = { "cmd" => "/bin/up" }
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 --health-cmd \"(/bin/up) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
"docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-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\" dhh/app:999",
new_command.run.join(" ")
end
@@ -52,40 +51,22 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "healthcheck" => { "cmd" => "/bin/healthy" } } }
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 --health-cmd \"(/bin/healthy) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
"docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-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\" dhh/app:999",
new_command.run.join(" ")
end
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 } } }
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",
new_command(role: "jobs", host: "1.1.1.2").run.join(" ")
"docker run --detach --restart unless-stopped --name app-jobs-999 -e KAMAL_CONTAINER_NAME=\"app-jobs-999\" --env-file .kamal/env/roles/app-jobs.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"jobs\" --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs",
new_command(role: "jobs").run.join(" ")
end
test "run with logging config" do
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } }
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 --health-cmd \"(curl -f http://localhost:3000/up || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
new_command.run.join(" ")
end
test "run with role logging config" do
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "10m", "max-file" => "3" } }
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "logging" => { "driver" => "local", "options" => { "max-size" => "100m" } } } }
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 --health-cmd \"(curl -f http://localhost:3000/up || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" 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\" --health-cmd \"(curl -f http://localhost:3000/up || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
"docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-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\" dhh/app:999",
new_command.run.join(" ")
end
@@ -102,16 +83,28 @@ class CommandsAppTest < ActiveSupport::TestCase
new_command.start.join(" ")
end
test "start_or_run" do
assert_equal \
"docker start app-web-999 || docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-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\" dhh/app:999",
new_command.start_or_run.join(" ")
end
test "start_or_run with hostname" do
assert_equal \
"docker start app-web-999 || docker run --detach --restart unless-stopped --name app-web-999 --hostname myhost -e KAMAL_CONTAINER_NAME=\"app-web-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\" dhh/app:999",
new_command.start_or_run(hostname: "myhost").join(" ")
end
test "stop" do
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(" ")
end
test "stop with custom stop wait time" do
@config[:stop_wait_time] = 30
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(" ")
end
@@ -137,157 +130,115 @@ class CommandsAppTest < ActiveSupport::TestCase
test "logs" do
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(" ")
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(" ")
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(" ")
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(" ")
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(" ")
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(" ")
end
test "follow logs" do
assert_equal \
"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'",
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",
new_command.follow_logs(host: "app-1")
assert_equal \
"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\"'",
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 | grep \"Completed\"",
new_command.follow_logs(host: "app-1", grep: "Completed")
assert_equal \
"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'",
new_command.follow_logs(host: "app-1", lines: 123)
assert_equal \
"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\"'",
new_command.follow_logs(host: "app-1", lines: 123, grep: "Completed")
end
test "execute in new container" do
assert_equal \
"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(" ")
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(" ")
new_command.execute_in_new_container("bin/rails", "db:setup").join(" ")
end
test "execute in new container with custom options" do
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } }
assert_equal \
"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
test "execute in existing container" do
assert_equal \
"docker exec app-web-999 bin/rails db:setup",
new_command.execute_in_existing_container("bin/rails", "db:setup", env: {}).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(" ")
new_command.execute_in_existing_container("bin/rails", "db:setup").join(" ")
end
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},
new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {})
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: {})
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", host: "app-1")
end
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 } } }
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: {})
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", host: "app-1")
end
test "execute in existing container over ssh" do
assert_match %r{docker exec -it app-web-999 bin/rails c},
new_command.execute_in_existing_container_over_ssh("bin/rails", "c", env: {})
assert_match %r|docker exec -it app-web-999 bin/rails c|,
new_command.execute_in_existing_container_over_ssh("bin/rails", "c", host: "app-1")
end
test "run over ssh" do
assert_equal "ssh -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
assert_equal "ssh -t root@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with custom user" do
@config[:ssh] = { "user" => "app" }
assert_equal "ssh -t app@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with custom port" do
@config[:ssh] = { "port" => "2222" }
assert_equal "ssh -t root@1.1.1.1 -p 2222 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
assert_equal "ssh -t app@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with proxy" do
@config[:ssh] = { "proxy" => "2.2.2.2" }
assert_equal "ssh -J root@2.2.2.2 -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
assert_equal "ssh -J root@2.2.2.2 -t root@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with proxy user" do
@config[:ssh] = { "proxy" => "app@2.2.2.2" }
assert_equal "ssh -J app@2.2.2.2 -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
assert_equal "ssh -J app@2.2.2.2 -t root@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with custom user with proxy" do
@config[:ssh] = { "user" => "app", "proxy" => "2.2.2.2" }
assert_equal "ssh -J root@2.2.2.2 -t app@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
assert_equal "ssh -J root@2.2.2.2 -t app@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with proxy_command" do
@config[:ssh] = { "proxy_command" => "ssh -W %h:%p user@proxy-server" }
assert_equal "ssh -o ProxyCommand='ssh -W %h:%p user@proxy-server' -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
assert_equal "ssh -o ProxyCommand='ssh -W %h:%p user@proxy-server' -t root@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
end
test "current_running_container_id" do
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(" ")
end
test "current_running_container_id with destination" do
@destination = "staging"
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(" ")
end
@@ -299,7 +250,7 @@ class CommandsAppTest < ActiveSupport::TestCase
test "current_running_version" do
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(" ")
end
@@ -377,17 +328,10 @@ class CommandsAppTest < ActiveSupport::TestCase
new_command.remove_images.join(" ")
end
test "tag_latest_image" do
test "tag_current_as_latest" do
assert_equal \
"docker tag dhh/app:999 dhh/app:latest",
new_command.tag_latest_image.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(" ")
new_command.tag_current_as_latest.join(" ")
end
test "make_env_directory" do
@@ -398,54 +342,8 @@ class CommandsAppTest < ActiveSupport::TestCase
assert_equal "rm -f .kamal/env/roles/app-web.env", new_command.remove_env_file.join(" ")
end
test "cord" do
assert_equal "docker inspect -f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}' app-web-123 | awk '$2 == \"/tmp/kamal-cord\" {print $1}'", new_command.cord(version: 123).join(" ")
end
test "tie cord" do
assert_equal "mkdir -p . ; touch cordfile", new_command.tie_cord("cordfile").join(" ")
assert_equal "mkdir -p corddir ; touch corddir/cordfile", new_command.tie_cord("corddir/cordfile").join(" ")
assert_equal "mkdir -p /corddir ; touch /corddir/cordfile", new_command.tie_cord("/corddir/cordfile").join(" ")
end
test "cut cord" do
assert_equal "rm -r corddir", new_command.cut_cord("corddir").join(" ")
end
test "extract assets" do
assert_equal [
:mkdir, "-p", ".kamal/assets/extracted/app-web-999", "&&",
: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, :cp, "-L", "app-web-assets:/public/assets/.", ".kamal/assets/extracted/app-web-999", "&&",
:docker, :stop, "-t 1", "app-web-assets"
], new_command(asset_path: "/public/assets").extract_assets
end
test "sync asset volumes" do
assert_equal [
:mkdir, "-p", ".kamal/assets/volumes/app-web-999", ";",
:cp, "-rnT", ".kamal/assets/extracted/app-web-999", ".kamal/assets/volumes/app-web-999"
], new_command(asset_path: "/public/assets").sync_asset_volumes
assert_equal [
:mkdir, "-p", ".kamal/assets/volumes/app-web-999", ";",
:cp, "-rnT", ".kamal/assets/extracted/app-web-999", ".kamal/assets/volumes/app-web-999", ";",
:cp, "-rnT", ".kamal/assets/extracted/app-web-999", ".kamal/assets/volumes/app-web-998", "|| true", ";",
:cp, "-rnT", ".kamal/assets/extracted/app-web-998", ".kamal/assets/volumes/app-web-999", "|| true"
], new_command(asset_path: "/public/assets").sync_asset_volumes(old_version: 998)
end
test "clean up assets" do
assert_equal [
:find, ".kamal/assets/extracted", "-maxdepth 1", "-name", "'app-web-*'", "!", "-name", "app-web-999", "-exec rm -rf \"{}\" +", ";",
:find, ".kamal/assets/volumes", "-maxdepth 1", "-name", "'app-web-*'", "!", "-name", "app-web-999", "-exec rm -rf \"{}\" +"
], new_command(asset_path: "/public/assets").clean_up_assets
end
private
def new_command(role: "web", host: "1.1.1.1", **additional_config)
config = Kamal::Configuration.new(@config.merge(additional_config), destination: @destination, version: "999")
Kamal::Commands::App.new(config, role: config.role(role), host: host)
def new_command(role: "web")
Kamal::Commands::App.new(Kamal::Configuration.new(@config, destination: @destination, version: "999"), role: role)
end
end

View File

@@ -6,7 +6,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
end
test "target multiarch by default" do
builder = new_builder_command(builder: { "cache" => { "type" => "gha" } })
builder = new_builder_command(builder: { "cache" => { "type" => "gha" }})
assert_equal "multiarch", builder.name
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 .",
@@ -22,7 +22,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
end
test "target native cached when multiarch is off and cache is set" do
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 \
"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 .",
@@ -30,21 +30,13 @@ class CommandsBuilderTest < ActiveSupport::TestCase
end
test "target multiarch remote when local and remote is set" do
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 \
"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(" ")
end
test "target multiarch local when arch is set" do
builder = new_builder_command(builder: { "local" => { "arch" => "amd64" } })
assert_equal "multiarch", builder.name
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 .",
builder.push.join(" ")
end
test "target native remote when only remote is set" do
builder = new_builder_command(builder: { "remote" => { "arch" => "amd64" }, "cache" => { "type" => "gha" } })
assert_equal "native/remote", builder.name
@@ -61,7 +53,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
end
test "build secrets" do
builder = new_builder_command(builder: { "secrets" => [ "token_a", "token_b" ] })
builder = new_builder_command(builder: { "secrets" => ["token_a", "token_b"] })
assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"token_a\" --secret id=\"token_b\" --file Dockerfile",
builder.target.build_options.join(" ")
@@ -83,13 +75,6 @@ class CommandsBuilderTest < ActiveSupport::TestCase
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
builder = new_builder_command(builder: { "context" => ".." })
assert_equal \
@@ -118,52 +103,8 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder.push.join(" ")
end
test "build with ssh agent socket" do
builder = new_builder_command(builder: { "ssh" => "default=$SSH_AUTH_SOCK" })
assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --ssh default=$SSH_AUTH_SOCK",
builder.target.build_options.join(" ")
end
test "validate image" do
assert_equal "docker inspect -f '{{ .Config.Labels.service }}' dhh/app:123 | grep -x app || (echo \"Image dhh/app:123 is missing the 'service' label\" && exit 1)", new_builder_command.validate_image.join(" ")
end
test "multiarch context build" do
builder = new_builder_command(builder: { "context" => "./foo" })
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\" --file Dockerfile ./foo",
builder.push.join(" ")
end
test "native context build" do
builder = new_builder_command(builder: { "multiarch" => false, "context" => "./foo" })
assert_equal \
"docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile ./foo && docker push dhh/app:123 && docker push dhh/app:latest",
builder.push.join(" ")
end
test "cached context build" do
builder = new_builder_command(builder: { "multiarch" => false, "context" => "./foo", "cache" => { "type" => "gha" } })
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 ./foo",
builder.push.join(" ")
end
test "remote context build" do
builder = new_builder_command(builder: { "remote" => { "arch" => "amd64" }, "context" => "./foo" })
assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-app-native-remote -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile ./foo",
builder.push.join(" ")
end
private
def new_builder_command(additional_config = {})
Kamal::Commands::Builder.new(Kamal::Configuration.new(@config.merge(additional_config), version: "123"))
end
def build_directory
"#{Dir.tmpdir}/kamal-clones/app/kamal/"
end
end

View File

@@ -9,7 +9,7 @@ class CommandsDockerTest < ActiveSupport::TestCase
end
test "install" do
assert_equal "sh -c 'curl -fsSL https://get.docker.com || wget -O - https://get.docker.com || echo \"exit 1\"' | sh", @docker.install.join(" ")
assert_equal "curl -fsSL https://get.docker.com | sh", @docker.install.join(" ")
end
test "installed?" do
@@ -21,6 +21,6 @@ class CommandsDockerTest < ActiveSupport::TestCase
end
test "superuser?" do
assert_equal '[ "${EUID:-$(id -u)}" -eq 0 ] || command -v sudo >/dev/null || command -v su >/dev/null', @docker.superuser?.join(" ")
assert_equal '[ "${EUID:-$(id -u)}" -eq 0 ]', @docker.superuser?.join(" ")
end
end

View File

@@ -0,0 +1,106 @@
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" ],
traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
}
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" } } }
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\" --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 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

@@ -10,24 +10,24 @@ class CommandsLockTest < ActiveSupport::TestCase
test "status" do
assert_equal \
"stat .kamal/locks/app-production > /dev/null && cat .kamal/locks/app-production/details | base64 -d",
"stat .kamal/lock-app > /dev/null && cat .kamal/lock-app/details | base64 -d",
new_command.status.join(" ")
end
test "acquire" do
assert_match \
%r{mkdir \.kamal/locks/app-production && echo ".*" > \.kamal/locks/app-production/details}m,
%r{mkdir \.kamal/lock-app && echo ".*" > \.kamal/lock-app/details}m,
new_command.acquire("Hello", "123").join(" ")
end
test "release" do
assert_match \
"rm .kamal/locks/app-production/details && rm -r .kamal/locks/app-production",
"rm .kamal/lock-app/details && rm -r .kamal/lock-app",
new_command.release.join(" ")
end
private
def new_command
Kamal::Commands::Lock.new(Kamal::Configuration.new(@config, version: "123", destination: "production"))
Kamal::Commands::Lock.new(Kamal::Configuration.new(@config, version: "123"))
end
end

View File

@@ -10,7 +10,7 @@ class CommandsPruneTest < ActiveSupport::TestCase
test "dangling images" do
assert_equal \
"docker image prune --force --filter label=service=app",
"docker image prune --force --filter label=service=app --filter dangling=true",
new_command.dangling_images.join(" ")
end
@@ -20,20 +20,10 @@ class CommandsPruneTest < ActiveSupport::TestCase
new_command.tagged_images.join(" ")
end
test "app containers" do
test "containers" do
assert_equal \
"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",
new_command.app_containers(retain: 5).join(" ")
assert_equal \
"docker ps -q -a --filter label=service=app --filter status=created --filter status=exited --filter status=dead | tail -n +4 | while read container_id; do docker rm $container_id; done",
new_command.app_containers(retain: 3).join(" ")
end
test "healthcheck containers" do
assert_equal \
"docker container prune --force --filter label=service=healthcheck-app",
new_command.healthcheck_containers.join(" ")
new_command.containers.join(" ")
end
private

View File

@@ -15,7 +15,7 @@ class CommandsRegistryTest < ActiveSupport::TestCase
test "registry login" do
assert_equal \
"docker login hub.docker.com -u \"dhh\" -p \"secret\"",
"docker login hub.docker.com -u dhh -p secret",
@registry.login.join(" ")
end
@@ -24,18 +24,7 @@ class CommandsRegistryTest < ActiveSupport::TestCase
@config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ]
assert_equal \
"docker login hub.docker.com -u \"dhh\" -p \"more-secret\"",
@registry.login.join(" ")
ensure
ENV.delete("KAMAL_REGISTRY_PASSWORD")
end
test "registry login escape password" do
ENV["KAMAL_REGISTRY_PASSWORD"] = "more-secret'\""
@config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ]
assert_equal \
"docker login hub.docker.com -u \"dhh\" -p \"more-secret'\\\"\"",
"docker login hub.docker.com -u dhh -p more-secret",
@registry.login.join(" ")
ensure
ENV.delete("KAMAL_REGISTRY_PASSWORD")
@@ -46,7 +35,7 @@ class CommandsRegistryTest < ActiveSupport::TestCase
@config[:registry]["username"] = [ "KAMAL_REGISTRY_USERNAME" ]
assert_equal \
"docker login hub.docker.com -u \"also-secret\" -p \"secret\"",
"docker login hub.docker.com -u also-secret -p secret",
@registry.login.join(" ")
ensure
ENV.delete("KAMAL_REGISTRY_USERNAME")

View File

@@ -0,0 +1,192 @@
require "test_helper"
class CommandsTraefikStaticTest < ActiveSupport::TestCase
setup do
@image = "traefik:test"
@config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
traefik: { "image" => @image, "args" => { "accesslog.format" => "json", "api.insecure" => true, "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
}
ENV["EXAMPLE_API_KEY"] = "456"
end
teardown do
ENV.delete("EXAMPLE_API_KEY")
end
test "run" do
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" #{@image} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
@config[:traefik]["host_port"] = "8080"
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 8080:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" #{@image} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
@config[:traefik]["publish"] = false
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" #{@image} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end
test "run with ports configured" do
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" #{@image} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
@config[:traefik]["options"] = {"publish" => %w[9000:9000 9001:9001]}
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --publish \"9000:9000\" --publish \"9001:9001\" #{@image} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end
test "run with volumes configured" do
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" #{@image} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
@config[:traefik]["options"] = {"volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json] }
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" #{@image} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end
test "run with several options configured" do
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" #{@image} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
@config[:traefik]["options"] = {"volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json], "publish" => %w[8080:8080], "memory" => "512m"}
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" --publish \"8080:8080\" --memory \"512m\" #{@image} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end
test "run with labels configured" do
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" #{@image} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
@config[:traefik]["labels"] = { "traefik.http.routers.dashboard.service" => "api@internal", "traefik.http.routers.dashboard.middlewares" => "auth" }
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.dashboard.service=\"api@internal\" --label traefik.http.routers.dashboard.middlewares=\"auth\" #{@image} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end
test "run with env configured" do
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" #{@image} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
@config[:traefik]["env"] = { "secret" => %w[EXAMPLE_API_KEY] }
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" #{@image} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end
test "run without configuration" do
@config.delete(:traefik)
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" #{Kamal::Configuration::Traefik::Static::DEFAULT_IMAGE} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\"",
new_command.run.join(" ")
end
test "run with logging config" do
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } }
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" #{@image} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end
test "run with default args overriden" do
@config[:traefik]["args"]["log.level"] = "ERROR"
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/traefik-config:/var/run/traefik-config --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" #{@image} --providers.docker --providers.file.directory=\"/var/run/traefik-config\" --providers.file.watch --log.level=\"ERROR\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end
test "traefik start" do
assert_equal \
"docker container start traefik",
new_command.start.join(" ")
end
test "traefik stop" do
assert_equal \
"docker container stop traefik",
new_command.stop.join(" ")
end
test "traefik info" do
assert_equal \
"docker ps --filter name=^traefik$",
new_command.info.join(" ")
end
test "traefik logs" do
assert_equal \
"docker logs traefik --timestamps 2>&1",
new_command.logs.join(" ")
end
test "traefik logs since 2h" do
assert_equal \
"docker logs traefik --since 2h --timestamps 2>&1",
new_command.logs(since: '2h').join(" ")
end
test "traefik logs last 10 lines" do
assert_equal \
"docker logs traefik --tail 10 --timestamps 2>&1",
new_command.logs(lines: 10).join(" ")
end
test "traefik logs with grep hello!" do
assert_equal \
"docker logs traefik --timestamps 2>&1 | grep 'hello!'",
new_command.logs(grep: 'hello!').join(" ")
end
test "traefik remove container" do
assert_equal \
"docker container prune --force --filter label=org.opencontainers.image.title=Traefik",
new_command.remove_container.join(" ")
end
test "traefik remove image" do
assert_equal \
"docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik",
new_command.remove_image.join(" ")
end
test "traefik follow logs" do
assert_equal \
"ssh -t root@1.1.1.1 'docker logs traefik --timestamps --tail 10 --follow 2>&1'",
new_command.follow_logs(host: @config[:servers].first)
end
test "traefik follow logs with grep hello!" do
assert_equal \
"ssh -t root@1.1.1.1 'docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hello!\"'",
new_command.follow_logs(host: @config[:servers].first, grep: 'hello!')
end
test "make_env_directory" do
assert_equal "mkdir -p .kamal/env/traefik", new_command.make_env_directory.join(" ")
end
test "remove_env_file" do
assert_equal "rm -f .kamal/env/traefik/traefik.env", new_command.remove_env_file.join(" ")
end
private
def new_command
Kamal::Commands::Traefik::Static.new(Kamal::Configuration.new(@config, version: "123"))
end
end

Some files were not shown because too many files have changed in this diff Show More