Compare commits

..

9 Commits

Author SHA1 Message Date
Donal McBreen
b21af0f5b3 Merge remote-tracking branch 'origin/revert-905-simplify-builders-config' into proxy-experimental-and-revert-905-simplify-builders-config
* origin/revert-905-simplify-builders-config:
  Revert "Simplify builders config"
2024-08-29 20:18:17 +01:00
Donal McBreen
459ba95bbf Revert "Simplify builders config" 2024-08-29 20:16:34 +01:00
Donal McBreen
01d08738ff Fix merge error 2024-08-29 09:49:03 +01:00
Donal McBreen
19cf94457f Read buffer not buffering 2024-08-29 09:49:03 +01:00
Donal McBreen
269a5ff3e6 Set request and response headers 2024-08-29 09:49:03 +01:00
Donal McBreen
c8adda1550 Split buffer requests/responses 2024-08-29 09:49:03 +01:00
Donal McBreen
d891eb91e4 Add forward headers support 2024-08-29 09:49:03 +01:00
Donal McBreen
268ec1c6e0 Set extra fields 2024-08-29 09:49:03 +01:00
Donal McBreen
42fdbd98cb Add kamal-proxy in experimental mode
The proxy can be enabled via the config:

```
proxy:
  enabled: true
  hosts:
    - 10.0.0.1
    - 10.0.0.2
```

This will enable the proxy and cause it to be run on the hosts listed
under `hosts`, after running `kamal proxy reboot`.

Enabling the proxy disables `kamal traefik` commands and replaces them
with `kamal proxy` ones. However only the marked hosts will run the
kamal-proxy container, the rest will run Traefik as before.
2024-08-29 09:49:03 +01:00
146 changed files with 1369 additions and 2234 deletions

View File

@@ -6,7 +6,7 @@ PATH
base64 (~> 0.2) base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)
concurrent-ruby (~> 1.2) concurrent-ruby (~> 1.2)
dotenv (~> 3.1) dotenv (~> 2.8)
ed25519 (~> 1.2) ed25519 (~> 1.2)
net-ssh (~> 7.0) net-ssh (~> 7.0)
sshkit (>= 1.23.0, < 2.0) sshkit (>= 1.23.0, < 2.0)
@@ -16,9 +16,9 @@ PATH
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actionpack (7.1.3.4) actionpack (7.1.2)
actionview (= 7.1.3.4) actionview (= 7.1.2)
activesupport (= 7.1.3.4) activesupport (= 7.1.2)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc racc
rack (>= 2.2.4) rack (>= 2.2.4)
@@ -26,13 +26,13 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
actionview (7.1.3.4) actionview (7.1.2)
activesupport (= 7.1.3.4) activesupport (= 7.1.2)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activesupport (7.1.3.4) activesupport (7.1.2)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
@@ -44,55 +44,54 @@ GEM
tzinfo (~> 2.0) tzinfo (~> 2.0)
ast (2.4.2) ast (2.4.2)
base64 (0.2.0) base64 (0.2.0)
bcrypt_pbkdf (1.1.1) bcrypt_pbkdf (1.1.0)
bcrypt_pbkdf (1.1.1-arm64-darwin) bigdecimal (3.1.5)
bcrypt_pbkdf (1.1.1-x86_64-darwin) builder (3.2.4)
bigdecimal (3.1.8) concurrent-ruby (1.2.2)
builder (3.3.0)
concurrent-ruby (1.3.3)
connection_pool (2.4.1) connection_pool (2.4.1)
crass (1.0.6) crass (1.0.6)
debug (1.9.2) debug (1.9.1)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
dotenv (3.1.2) dotenv (2.8.1)
drb (2.2.1) drb (2.2.0)
ruby2_keywords
ed25519 (1.3.0) ed25519 (1.3.0)
erubi (1.13.0) erubi (1.12.0)
i18n (1.14.5) i18n (1.14.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
io-console (0.7.2) io-console (0.7.1)
irb (1.14.0) irb (1.11.0)
rdoc (>= 4.0.0) rdoc
reline (>= 0.4.2) reline (>= 0.3.8)
json (2.7.2) json (2.7.1)
language_server-protocol (3.17.0.3) language_server-protocol (3.17.0.3)
loofah (2.22.0) loofah (2.22.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
minitest (5.24.1) minitest (5.20.0)
mocha (2.4.5) mocha (2.1.0)
ruby2_keywords (>= 0.0.5) ruby2_keywords (>= 0.0.5)
mutex_m (0.2.0) mutex_m (0.2.0)
net-scp (4.0.0) net-scp (4.0.0)
net-ssh (>= 2.6.5, < 8.0.0) net-ssh (>= 2.6.5, < 8.0.0)
net-sftp (4.0.0) net-sftp (4.0.0)
net-ssh (>= 5.0.0, < 8.0.0) net-ssh (>= 5.0.0, < 8.0.0)
net-ssh (7.2.3) net-ssh (7.2.1)
nokogiri (1.16.7-arm64-darwin) nokogiri (1.16.0-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.7-x86_64-darwin) nokogiri (1.16.0-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.7-x86_64-linux) nokogiri (1.16.0-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
parallel (1.25.1) parallel (1.24.0)
parser (3.3.4.0) parser (3.3.0.5)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
psych (5.1.2) psych (5.1.2)
stringio stringio
racc (1.8.1) racc (1.7.3)
rack (3.1.7) rack (3.0.8)
rack-session (2.0.0) rack-session (2.0.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.1.0) rack-test (2.1.0)
@@ -107,43 +106,42 @@ GEM
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.6.0)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (~> 1.14) nokogiri (~> 1.14)
railties (7.1.3.4) railties (7.1.2)
actionpack (= 7.1.3.4) actionpack (= 7.1.2)
activesupport (= 7.1.3.4) activesupport (= 7.1.2)
irb irb
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.1.0)
rdoc (6.7.0) rdoc (6.6.2)
psych (>= 4.0.0) psych (>= 4.0.0)
regexp_parser (2.9.2) regexp_parser (2.9.0)
reline (0.5.9) reline (0.4.2)
io-console (~> 0.5) io-console (~> 0.5)
rexml (3.3.4) rexml (3.2.6)
strscan rubocop (1.62.1)
rubocop (1.65.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.4, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.32.0) rubocop-ast (1.31.2)
parser (>= 3.3.1.0) parser (>= 3.3.0.4)
rubocop-minitest (0.35.1) rubocop-minitest (0.35.0)
rubocop (>= 1.61, < 2.0) rubocop (>= 1.61, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
rubocop-performance (1.21.1) rubocop-performance (1.20.2)
rubocop (>= 1.48.1, < 2.0) rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.30.0, < 2.0)
rubocop-rails (2.25.1) rubocop-rails (2.24.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0) rubocop (>= 1.33.0, < 2.0)
@@ -160,14 +158,13 @@ GEM
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-sftp (>= 2.1.2) net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
stringio (3.1.1) stringio (3.1.0)
strscan (3.1.0) thor (1.3.0)
thor (1.3.1)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0) unicode-display_width (2.5.0)
webrick (1.8.1) webrick (1.8.1)
zeitwerk (2.6.17) zeitwerk (2.6.12)
PLATFORMS PLATFORMS
arm64-darwin arm64-darwin

View File

@@ -15,7 +15,7 @@ Gem::Specification.new do |spec|
spec.add_dependency "sshkit", ">= 1.23.0", "< 2.0" spec.add_dependency "sshkit", ">= 1.23.0", "< 2.0"
spec.add_dependency "net-ssh", "~> 7.0" spec.add_dependency "net-ssh", "~> 7.0"
spec.add_dependency "thor", "~> 1.3" spec.add_dependency "thor", "~> 1.3"
spec.add_dependency "dotenv", "~> 3.1" spec.add_dependency "dotenv", "~> 2.8"
spec.add_dependency "zeitwerk", "~> 2.5" spec.add_dependency "zeitwerk", "~> 2.5"
spec.add_dependency "ed25519", "~> 1.2" spec.add_dependency "ed25519", "~> 1.2"
spec.add_dependency "bcrypt_pbkdf", "~> 1.0" spec.add_dependency "bcrypt_pbkdf", "~> 1.0"

View File

@@ -5,10 +5,8 @@ end
require "active_support" require "active_support"
require "zeitwerk" require "zeitwerk"
require "yaml" require "yaml"
require "tmpdir"
require "pathname"
loader = Zeitwerk::Loader.for_gem loader = Zeitwerk::Loader.for_gem
loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb")) loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb"))
loader.setup loader.setup
loader.eager_load_namespace(Kamal::Cli) # We need all commands loaded. loader.eager_load # We need all commands loaded.

View File

@@ -1,20 +1,17 @@
class Kamal::Cli::Accessory < Kamal::Cli::Base class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "boot [NAME]", "Boot new accessory service on host (use NAME=all to boot all accessories)" desc "boot [NAME]", "Boot new accessory service on host (use NAME=all to boot all accessories)"
def boot(name, prepare: true) def boot(name, login: true)
with_lock do with_lock do
if name == "all" if name == "all"
KAMAL.accessory_names.each { |accessory_name| boot(accessory_name) } KAMAL.accessory_names.each { |accessory_name| boot(accessory_name) }
else else
prepare(name) if prepare
with_accessory(name) do |accessory, hosts| with_accessory(name) do |accessory, hosts|
directories(name) directories(name)
upload(name) upload(name)
on(hosts) do on(hosts) do
execute *KAMAL.registry.login if login
execute *KAMAL.auditor.record("Booted #{name} accessory"), verbosity: :debug execute *KAMAL.auditor.record("Booted #{name} accessory"), verbosity: :debug
execute *accessory.ensure_env_directory
upload! accessory.secrets_io, accessory.secrets_path, mode: "0600"
execute *accessory.run execute *accessory.run
end end
end end
@@ -58,10 +55,15 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
if name == "all" if name == "all"
KAMAL.accessory_names.each { |accessory_name| reboot(accessory_name) } KAMAL.accessory_names.each { |accessory_name| reboot(accessory_name) }
else else
prepare(name) with_accessory(name) do |accessory, hosts|
stop(name) on(hosts) do
remove_container(name) execute *KAMAL.registry.login
boot(name, prepare: false) end
stop(name)
remove_container(name)
boot(name, login: false)
end
end end
end end
end end
@@ -93,8 +95,10 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
desc "restart [NAME]", "Restart existing accessory container on host" desc "restart [NAME]", "Restart existing accessory container on host"
def restart(name) def restart(name)
with_lock do with_lock do
stop(name) with_accessory(name) do
start(name) stop(name)
start(name)
end
end end
end end
@@ -245,20 +249,11 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
end end
def remove_accessory(name) def remove_accessory(name)
stop(name) with_accessory(name) do
remove_container(name) stop(name)
remove_image(name) remove_container(name)
remove_service_directory(name) remove_image(name)
end remove_service_directory(name)
def prepare(name)
with_accessory(name) do |accessory, hosts|
on(hosts) do
execute *KAMAL.registry.login
execute *KAMAL.docker.create_network
rescue SSHKit::Command::Failed => e
raise unless e.message.include?("already exists")
end
end end
end end
end end

View File

@@ -44,7 +44,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
if role.running_traefik? && KAMAL.proxy_host?(host) if role.running_traefik? && KAMAL.proxy_host?(host)
version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
endpoint = capture_with_info(*app.container_id_for_version(version)).strip endpoint = capture_with_info(*app.container_endpoint(version: version)).strip
raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty? raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
execute *KAMAL.proxy.deploy(role.container_prefix, target: endpoint) execute *KAMAL.proxy.deploy(role.container_prefix, target: endpoint)
@@ -66,7 +66,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
if role.running_traefik? && KAMAL.proxy_host?(host) if role.running_traefik? && KAMAL.proxy_host?(host)
version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
endpoint = capture_with_info(*app.container_id_for_version(version)).strip endpoint = capture_with_info(*app.container_endpoint(version: version)).strip
if endpoint.present? if endpoint.present?
execute *KAMAL.proxy.remove(role.container_prefix, target: endpoint), raise_on_non_zero_exit: false execute *KAMAL.proxy.remove(role.container_prefix, target: endpoint), raise_on_non_zero_exit: false
end end

View File

@@ -1,6 +1,6 @@
class Kamal::Cli::App::Boot class Kamal::Cli::App::Boot
attr_reader :host, :role, :version, :barrier, :sshkit attr_reader :host, :role, :version, :barrier, :sshkit
delegate :execute, :capture_with_info, :capture_with_pretty_json, :info, :error, :upload!, to: :sshkit delegate :execute, :capture_with_info, :capture_with_pretty_json, :info, :error, to: :sshkit
delegate :uses_cord?, :assets?, :running_traefik?, to: :role delegate :uses_cord?, :assets?, :running_traefik?, to: :role
def initialize(host, role, sshkit, version, barrier) def initialize(host, role, sshkit, version, barrier)
@@ -47,13 +47,10 @@ class Kamal::Cli::App::Boot
audit "Booted app version #{version}" audit "Booted app version #{version}"
hostname = "#{host.to_s[0...51].gsub(/\.+$/, '')}-#{SecureRandom.hex(6)}" hostname = "#{host.to_s[0...51].gsub(/\.+$/, '')}-#{SecureRandom.hex(6)}"
execute *app.ensure_env_directory
upload! role.secrets_io(host), role.secrets_path, mode: "0600"
if proxy_host? if proxy_host?
execute *app.run_for_proxy(hostname: hostname) execute *app.run_for_proxy(hostname: hostname)
if running_traefik? if running_traefik?
endpoint = capture_with_info(*app.container_id_for_version(version)).strip endpoint = capture_with_info(*app.container_endpoint(version: version)).strip
raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty? raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
execute *KAMAL.proxy.deploy(role.container_prefix, target: endpoint) execute *KAMAL.proxy.deploy(role.container_prefix, target: endpoint)
else else
@@ -102,12 +99,8 @@ class Kamal::Cli::App::Boot
def close_barrier def close_barrier
if barrier.close if barrier.close
info "First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting any other roles" info "First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting any other roles"
begin error capture_with_info(*app.logs(version: version))
error capture_with_info(*app.logs(version: version)) error capture_with_info(*app.container_health_log(version: version))
error capture_with_info(*app.container_health_log(version: version))
rescue SSHKit::Command::Failed
error "Could not fetch logs for #{version}"
end
end end
end end

View File

@@ -1,4 +1,5 @@
require "thor" require "thor"
require "dotenv"
require "kamal/sshkit_with_ext" require "kamal/sshkit_with_ext"
module Kamal::Cli module Kamal::Cli
@@ -30,15 +31,53 @@ module Kamal::Cli
else else
super super
end end
initialize_commander unless KAMAL.configured? @original_env = ENV.to_h.dup
load_env
initialize_commander(options_with_subcommand_class_options)
end end
private private
def reload_env
reset_env
load_env
end
def load_env
if destination = options[:destination]
Dotenv.load(".env.#{destination}", ".env")
else
Dotenv.load(".env")
end
end
def reset_env
replace_env @original_env
end
def replace_env(env)
ENV.clear
ENV.update(env)
end
def with_original_env
keeping_current_env do
reset_env
yield
end
end
def keeping_current_env
current_env = ENV.to_h.dup
yield
ensure
replace_env(current_env)
end
def options_with_subcommand_class_options def options_with_subcommand_class_options
options.merge(@_initializer.last[:class_options] || {}) options.merge(@_initializer.last[:class_options] || {})
end end
def initialize_commander def initialize_commander(options)
KAMAL.tap do |commander| KAMAL.tap do |commander|
if options[:verbose] if options[:verbose]
ENV["VERBOSE"] = "1" # For backtraces via cli/start ENV["VERBOSE"] = "1" # For backtraces via cli/start
@@ -73,6 +112,8 @@ module Kamal::Cli
if KAMAL.holding_lock? if KAMAL.holding_lock?
yield yield
else else
ensure_run_and_locks_directory
acquire_lock acquire_lock
begin begin
@@ -101,8 +142,6 @@ module Kamal::Cli
end end
def acquire_lock def acquire_lock
ensure_run_and_locks_directory
raise_if_locked do raise_if_locked do
say "Acquiring the deploy lock...", :magenta say "Acquiring the deploy lock...", :magenta
on(KAMAL.primary_host) { execute *KAMAL.lock.acquire("Automatic deploy lock", KAMAL.config.version), verbosity: :debug } on(KAMAL.primary_host) { execute *KAMAL.lock.acquire("Automatic deploy lock", KAMAL.config.version), verbosity: :debug }

View File

@@ -30,28 +30,29 @@ class Kamal::Cli::Build < Kamal::Cli::Base
say "Building with uncommitted changes:\n #{uncommitted_changes}", :yellow say "Building with uncommitted changes:\n #{uncommitted_changes}", :yellow
end end
# Get the command here to ensure the Dir.chdir doesn't interfere with it
push = KAMAL.builder.push
run_locally do run_locally do
begin begin
execute *KAMAL.builder.inspect_builder context_hosts = capture_with_info(*KAMAL.builder.context_hosts).split("\n")
if context_hosts != KAMAL.builder.config_context_hosts
warn "Context hosts have changed, so re-creating builder, was: #{context_hosts.join(", ")}], now: #{KAMAL.builder.config_context_hosts.join(", ")}"
cli.remove
cli.create
end
rescue SSHKit::Command::Failed => e rescue SSHKit::Command::Failed => e
if e.message =~ /(context not found|no builder|no compatible builder|does not exist)/ if e.message =~ /(context not found|no builder|does not exist)/
warn "Missing compatible builder, so creating a new one first" warn "Missing compatible builder, so creating a new one first"
begin
cli.remove
rescue SSHKit::Command::Failed
raise unless e.message =~ /(context not found|no builder|does not exist)/
end
cli.create cli.create
else else
raise raise
end end
end end
# Get the command here to ensure the Dir.chdir doesn't interfere with it
push = KAMAL.builder.push
KAMAL.with_verbosity(:debug) do KAMAL.with_verbosity(:debug) do
Dir.chdir(KAMAL.config.builder.build_directory) { execute *push, env: KAMAL.config.builder.secrets } Dir.chdir(KAMAL.config.builder.build_directory) { execute *push }
end end
end end
end end
@@ -71,7 +72,7 @@ class Kamal::Cli::Build < Kamal::Cli::Base
desc "create", "Create a build setup" desc "create", "Create a build setup"
def create def create
if (remote_host = KAMAL.config.builder.remote) if (remote_host = KAMAL.config.builder.remote_host)
connect_to_remote_host(remote_host) connect_to_remote_host(remote_host)
end end

54
lib/kamal/cli/env.rb Normal file
View File

@@ -0,0 +1,54 @@
require "tempfile"
class Kamal::Cli::Env < Kamal::Cli::Base
desc "push", "Push the env file to the remote hosts"
def push
with_lock 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
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
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
end
end
end
end
desc "delete", "Delete the env file from the remote hosts"
def delete
with_lock 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
end
end
on(KAMAL.traefik_hosts) do
execute *KAMAL.traefik.remove_env_file
end
on(KAMAL.accessory_hosts) do
KAMAL.accessories_on(host).each do |accessory|
accessory_config = KAMAL.config.accessory(accessory)
execute *KAMAL.accessory(accessory).remove_env_file
end
end
end
end
end

View File

@@ -1,5 +1,3 @@
require "concurrent/ivar"
class Kamal::Cli::Healthcheck::Barrier class Kamal::Cli::Healthcheck::Barrier
def initialize def initialize
@ivar = Concurrent::IVar.new @ivar = Concurrent::IVar.new

View File

@@ -3,6 +3,7 @@ class Kamal::Cli::Lock < Kamal::Cli::Base
def status def status
handle_missing_lock do handle_missing_lock do
on(KAMAL.primary_host) do on(KAMAL.primary_host) do
execute *KAMAL.server.ensure_run_directory
puts capture_with_debug(*KAMAL.lock.status) puts capture_with_debug(*KAMAL.lock.status)
end end
end end
@@ -12,10 +13,9 @@ class Kamal::Cli::Lock < Kamal::Cli::Base
option :message, aliases: "-m", type: :string, desc: "A lock message", required: true option :message, aliases: "-m", type: :string, desc: "A lock message", required: true
def acquire def acquire
message = options[:message] message = options[:message]
ensure_run_and_locks_directory
raise_if_locked do raise_if_locked do
on(KAMAL.primary_host) do on(KAMAL.primary_host) do
execute *KAMAL.server.ensure_run_directory
execute *KAMAL.lock.acquire(message, KAMAL.config.version), verbosity: :debug execute *KAMAL.lock.acquire(message, KAMAL.config.version), verbosity: :debug
end end
say "Acquired the deploy lock" say "Acquired the deploy lock"
@@ -26,6 +26,7 @@ class Kamal::Cli::Lock < Kamal::Cli::Base
def release def release
handle_missing_lock do handle_missing_lock do
on(KAMAL.primary_host) do on(KAMAL.primary_host) do
execute *KAMAL.server.ensure_run_directory
execute *KAMAL.lock.release, verbosity: :debug execute *KAMAL.lock.release, verbosity: :debug
end end
say "Released the deploy lock" say "Released the deploy lock"

View File

@@ -9,6 +9,10 @@ class Kamal::Cli::Main < Kamal::Cli::Base
say "Ensure Docker is installed...", :magenta say "Ensure Docker is installed...", :magenta
invoke "kamal:cli:server:bootstrap", [], invoke_options invoke "kamal:cli:server:bootstrap", [], invoke_options
say "Evaluate and push env files...", :magenta
invoke "kamal:cli:main:envify", [], invoke_options
invoke "kamal:cli:env:push", [], invoke_options
invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options
deploy deploy
end end
@@ -33,7 +37,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
end end
with_lock do with_lock do
run_hook "pre-deploy", secrets: true run_hook "pre-deploy"
if KAMAL.config.proxy.enabled? if KAMAL.config.proxy.enabled?
say "Ensure Traefik/kamal-proxy is running...", :magenta say "Ensure Traefik/kamal-proxy is running...", :magenta
@@ -53,7 +57,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
end end
end end
run_hook "post-deploy", secrets: true, runtime: runtime.round run_hook "post-deploy", runtime: runtime.round
end end
desc "redeploy", "Deploy app to servers without bootstrapping servers, starting Traefik, pruning, and registry login" desc "redeploy", "Deploy app to servers without bootstrapping servers, starting Traefik, pruning, and registry login"
@@ -71,7 +75,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
end end
with_lock do with_lock do
run_hook "pre-deploy", secrets: true run_hook "pre-deploy"
say "Detect stale containers...", :magenta say "Detect stale containers...", :magenta
invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true) invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)
@@ -80,7 +84,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
end end
end end
run_hook "post-deploy", secrets: true, runtime: runtime.round run_hook "post-deploy", runtime: runtime.round
end end
desc "rollback [VERSION]", "Rollback app to VERSION" desc "rollback [VERSION]", "Rollback app to VERSION"
@@ -94,7 +98,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
old_version = nil old_version = nil
if container_available?(version) if container_available?(version)
run_hook "pre-deploy", secrets: true run_hook "pre-deploy"
invoke "kamal:cli:app:boot", [], invoke_options.merge(version: version) invoke "kamal:cli:app:boot", [], invoke_options.merge(version: version)
rolled_back = true rolled_back = true
@@ -104,7 +108,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
end end
end end
run_hook "post-deploy", secrets: true, runtime: runtime.round if rolled_back run_hook "post-deploy", runtime: runtime.round if rolled_back
end end
desc "details", "Show details about all containers" desc "details", "Show details about all containers"
@@ -157,10 +161,9 @@ class Kamal::Cli::Main < Kamal::Cli::Base
puts "Created configuration file in config/deploy.yml" puts "Created configuration file in config/deploy.yml"
end end
unless (secrets_file = Pathname.new(File.expand_path(".kamal/secrets"))).exist? unless (deploy_file = Pathname.new(File.expand_path(".env"))).exist?
FileUtils.mkdir_p secrets_file.dirname FileUtils.cp_r Pathname.new(File.expand_path("templates/template.env", __dir__)), deploy_file
FileUtils.cp_r Pathname.new(File.expand_path("templates/secrets", __dir__)), secrets_file puts "Created .env file"
puts "Created .kamal/secrets file"
end end
unless (hooks_dir = Pathname.new(File.expand_path(".kamal/hooks"))).exist? unless (hooks_dir = Pathname.new(File.expand_path(".kamal/hooks"))).exist?
@@ -185,6 +188,31 @@ class Kamal::Cli::Main < Kamal::Cli::Base
end end
end 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"
env_path = ".env.#{destination}"
else
env_template_path = ".env.erb"
env_path = ".env"
end
if Pathname.new(File.expand_path(env_template_path)).exist?
# Ensure existing env doesn't pollute template evaluation
content = with_original_env { ERB.new(File.read(env_template_path), trim_mode: "-").result }
File.write(env_path, content, perm: 0600)
unless options[:skip_push]
reload_env
invoke "kamal:cli:env:push", options
end
else
puts "Skipping envify (no #{env_template_path} exist)"
end
end
desc "remove", "Remove Traefik, app, accessories, and registry session from servers" desc "remove", "Remove Traefik, app, accessories, and registry session from servers"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def remove def remove
@@ -216,6 +244,9 @@ class Kamal::Cli::Main < Kamal::Cli::Base
desc "build", "Build application image" desc "build", "Build application image"
subcommand "build", Kamal::Cli::Build subcommand "build", Kamal::Cli::Build
desc "env", "Manage environment files"
subcommand "env", Kamal::Cli::Env
desc "lock", "Manage the deploy lock" desc "lock", "Manage the deploy lock"
subcommand "lock", Kamal::Cli::Lock subcommand "lock", Kamal::Cli::Lock
@@ -228,9 +259,6 @@ class Kamal::Cli::Main < Kamal::Cli::Base
desc "registry", "Login and -out of the image registry" desc "registry", "Login and -out of the image registry"
subcommand "registry", Kamal::Cli::Registry subcommand "registry", Kamal::Cli::Registry
desc "secrets", "Helpers for extracting secrets"
subcommand "secrets", Kamal::Cli::Secrets
desc "server", "Bootstrap servers with curl and Docker" desc "server", "Bootstrap servers with curl and Docker"
subcommand "server", Kamal::Cli::Server subcommand "server", Kamal::Cli::Server

View File

@@ -3,21 +3,9 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
def boot def boot
raise_unless_kamal_proxy_enabled! raise_unless_kamal_proxy_enabled!
with_lock do with_lock do
on(KAMAL.hosts) do |host|
execute *KAMAL.docker.create_network
rescue SSHKit::Command::Failed => e
raise unless e.message.include?("already exists")
end
on(KAMAL.traefik_hosts) do |host| on(KAMAL.traefik_hosts) do |host|
execute *KAMAL.registry.login execute *KAMAL.registry.login
if KAMAL.proxy_host?(host) execute *KAMAL.traefik_or_proxy(host).start_or_run
execute *KAMAL.proxy.start_or_run
else
execute *KAMAL.traefik.ensure_env_directory
upload! KAMAL.traefik.secrets_io, KAMAL.traefik.secrets_path, mode: "0600"
execute *KAMAL.traefik.start_or_run
end
end end
end end
end end
@@ -52,7 +40,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
app = KAMAL.app(role: role, host: host) app = KAMAL.app(role: role, host: host)
version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
endpoint = capture_with_info(*app.container_id_for_version(version)).strip endpoint = capture_with_info(*app.container_endpoint(version: version)).strip
if endpoint.present? if endpoint.present?
info "Deploying #{endpoint} for role `#{role}` on #{host}..." info "Deploying #{endpoint} for role `#{role}` on #{host}..."
@@ -67,43 +55,6 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
end end
end end
desc "upgrade", "Upgrade to correct proxy on servers (stop container, remove container, start new container)"
option :rolling, type: :boolean, default: false, desc: "Reboot proxy on hosts in sequence, rather than in parallel"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def upgrade
invoke_options = { "version" => KAMAL.config.version }.merge(options)
raise_unless_kamal_proxy_enabled!
confirming "This will cause a brief outage on each host. Are you sure?" do
host_groups = options[:rolling] ? KAMAL.hosts : [ KAMAL.hosts ]
host_groups.each do |hosts|
host_list = Array(hosts).join(",")
run_hook "pre-traefik-reboot", hosts: host_list
on(hosts) do |host|
execute *KAMAL.auditor.record("Rebooted proxy"), verbosity: :debug
execute *KAMAL.registry.login
"Stopping and removing Traefik on #{host}, if running..."
execute *KAMAL.traefik.stop, raise_on_non_zero_exit: false
execute *KAMAL.traefik.remove_container
"Stopping and removing kamal-proxy on #{host}, if running..."
execute *KAMAL.proxy.stop, raise_on_non_zero_exit: false
execute *KAMAL.proxy.remove_container
end
invoke "kamal:cli:proxy:boot", [], invoke_options.merge("hosts" => host_list)
reset_invocation(Kamal::Cli::Proxy)
invoke "kamal:cli:app:boot", [], invoke_options.merge("hosts" => host_list, version: KAMAL.config.latest_tag)
reset_invocation(Kamal::Cli::App)
invoke "kamal:cli:prune:all", [], invoke_options.merge("hosts" => host_list)
reset_invocation(Kamal::Cli::Prune)
run_hook "post-traefik-reboot", hosts: host_list
end
end
end
desc "start", "Start existing proxy container on servers" desc "start", "Start existing proxy container on servers"
def start def start
raise_unless_kamal_proxy_enabled! raise_unless_kamal_proxy_enabled!
@@ -206,8 +157,4 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
raise "kamal proxy commands are disabled unless experimental proxy support is enabled. Use `kamal traefik` commands instead." raise "kamal proxy commands are disabled unless experimental proxy support is enabled. Use `kamal traefik` commands instead."
end end
end end
def reset_invocation(cli_class)
instance_variable_get("@_invocations")[cli_class].pop
end
end end

View File

@@ -1,47 +0,0 @@
class Kamal::Cli::Secrets < Kamal::Cli::Base
desc "fetch [SECRETS...]", "Fetch secrets from a vault"
option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use"
option :account, type: :string, required: true, desc: "The account identifier or username"
option :from, type: :string, required: false, desc: "A vault or folder to fetch the secrets from"
option :inline, type: :boolean, required: false, hidden: true
def fetch(*secrets)
handle_output(inline: options[:inline]) do
results = adapter(options[:adapter]).fetch(secrets, **options.slice(:account, :from).symbolize_keys)
JSON.dump(results).shellescape
end
end
desc "extract", "Extract a single secret from the results of a fetch call"
option :inline, type: :boolean, required: false, hidden: true
def extract(name, secrets)
handle_output(inline: options[:inline]) do
parsed_secrets = JSON.parse(secrets)
value = parsed_secrets[name] || parsed_secrets.find { |k, v| k.end_with?("/#{name}") }&.last
raise "Could not find secret #{name}" if value.nil?
value
end
end
private
def adapter(adapter)
Kamal::Secrets::Adapters.lookup(adapter)
end
def handle_output(inline: nil)
yield.tap do |output|
puts output unless inline
end
rescue => e
handle_error(e)
end
def handle_error(e)
$stderr.puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m"
$stderr.puts e.backtrace if ENV["VERBOSE"]
exit 1
end
end

View File

@@ -18,10 +18,6 @@ registry:
password: password:
- KAMAL_REGISTRY_PASSWORD - KAMAL_REGISTRY_PASSWORD
# Configure builder setup.
builder:
arch: amd64
# Inject ENV variables into containers (secrets come from .env). # Inject ENV variables into containers (secrets come from .env).
# Remember to run `kamal env push` after making changes! # Remember to run `kamal env push` after making changes!
# env: # env:
@@ -34,6 +30,16 @@ builder:
# ssh: # ssh:
# user: app # user: app
# Configure builder setup.
# builder:
# args:
# RUBY_VERSION: 3.2.0
# secrets:
# - GITHUB_TOKEN
# remote:
# arch: amd64
# host: ssh://app@192.168.0.1
# Use accessory services (secrets come from .env). # Use accessory services (secrets come from .env).
# accessories: # accessories:
# db: # db:

View File

@@ -1,16 +0,0 @@
# WARNING: Avoid adding secrets directly to this file
# If you must, then add `.kamal/secrets*` to your .gitignore file
# Option 1: Read secrets from the environment
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
# Option 2: Read secrets via a command
# RAILS_MASTER_KEY=$(cat config/master.key)
# Option 3: Read secrets via kamal secrets helpers
# These will handle logging in and fetching the secrets in as few calls as possible
# There are adapters for 1Password, LastPass + Bitwarden
#
# SECRETS=$(kamal secrets fetch --adapter 1password --account my-account --from MyVault/MyItem KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY)
# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS)
# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS)

View File

@@ -0,0 +1,2 @@
KAMAL_REGISTRY_PASSWORD=change-this
RAILS_MASTER_KEY=another-env

View File

@@ -5,8 +5,6 @@ class Kamal::Cli::Traefik < Kamal::Cli::Base
with_lock do with_lock do
on(KAMAL.traefik_hosts) do on(KAMAL.traefik_hosts) do
execute *KAMAL.registry.login execute *KAMAL.registry.login
execute *KAMAL.traefik.ensure_env_directory
upload! KAMAL.traefik.secrets_io, KAMAL.traefik.secrets_path, mode: "0600"
execute *KAMAL.traefik.start_or_run execute *KAMAL.traefik.start_or_run
end end
end end

View File

@@ -1,6 +1,5 @@
require "active_support/core_ext/enumerable" require "active_support/core_ext/enumerable"
require "active_support/core_ext/module/delegation" require "active_support/core_ext/module/delegation"
require "active_support/core_ext/object/blank"
class Kamal::Commander class Kamal::Commander
attr_accessor :verbosity, :holding_lock, :connected attr_accessor :verbosity, :holding_lock, :connected
@@ -24,10 +23,6 @@ class Kamal::Commander
@config, @config_kwargs = nil, kwargs @config, @config_kwargs = nil, kwargs
end end
def configured?
@config || @config_kwargs
end
attr_reader :specific_roles, :specific_hosts attr_reader :specific_roles, :specific_hosts
def specific_primary! def specific_primary!

View File

@@ -23,7 +23,7 @@ class Kamal::Commander::Specifics
end end
def proxy_hosts def proxy_hosts
config.proxy_hosts traefik_hosts & config.proxy_hosts
end end
def proxy_host?(host) def proxy_host?(host)

View File

@@ -1,9 +1,7 @@
class Kamal::Commands::Accessory < Kamal::Commands::Base class Kamal::Commands::Accessory < Kamal::Commands::Base
attr_reader :accessory_config attr_reader :accessory_config
delegate :service_name, :image, :hosts, :port, :files, :directories, :cmd, delegate :service_name, :image, :hosts, :port, :files, :directories, :cmd,
:publish_args, :env_args, :volume_args, :label_args, :option_args, :publish_args, :env_args, :volume_args, :label_args, :option_args, to: :accessory_config
:secrets_io, :secrets_path, :env_directory,
to: :accessory_config
def initialize(config, name:) def initialize(config, name:)
super(config) super(config)
@@ -15,7 +13,6 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
"--name", service_name, "--name", service_name,
"--detach", "--detach",
"--restart", "unless-stopped", "--restart", "unless-stopped",
"--network", "kamal",
*config.logging_args, *config.logging_args,
*publish_args, *publish_args,
*env_args, *env_args,
@@ -64,7 +61,6 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
docker :run, docker :run,
("-it" if interactive), ("-it" if interactive),
"--rm", "--rm",
"--network", "kamal",
*env_args, *env_args,
*volume_args, *volume_args,
image, image,
@@ -102,8 +98,12 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
docker :image, :rm, "--force", image docker :image, :rm, "--force", image
end end
def ensure_env_directory def make_env_directory
make_directory env_directory make_directory accessory_config.env.secrets_directory
end
def remove_env_file
[ :rm, "-f", accessory_config.env.secrets_file ]
end end
private private

View File

@@ -5,8 +5,6 @@ class Kamal::Commands::App < Kamal::Commands::Base
attr_reader :role, :host attr_reader :role, :host
delegate :container_name, to: :role
def initialize(config, role: nil, host: nil) def initialize(config, role: nil, host: nil)
super(config) super(config)
@role = role @role = role
@@ -37,7 +35,6 @@ class Kamal::Commands::App < Kamal::Commands::Base
"--detach", "--detach",
"--restart unless-stopped", "--restart unless-stopped",
"--name", container_name, "--name", container_name,
"--network", "kamal",
*([ "--hostname", hostname ] if hostname), *([ "--hostname", hostname ] if hostname),
"-e", "KAMAL_CONTAINER_NAME=\"#{container_name}\"", "-e", "KAMAL_CONTAINER_NAME=\"#{container_name}\"",
"-e", "KAMAL_VERSION=\"#{config.version}\"", "-e", "KAMAL_VERSION=\"#{config.version}\"",
@@ -90,11 +87,21 @@ class Kamal::Commands::App < Kamal::Commands::Base
extract_version_from_name extract_version_from_name
end end
def ensure_env_directory
make_directory role.env_directory def make_env_directory
make_directory role.env(host).secrets_directory
end end
def remove_env_file
[ :rm, "-f", role.env(host).secrets_file ]
end
private private
def container_name(version = nil)
[ role.container_prefix, version || config.version ].compact.join("-")
end
def latest_image_id def latest_image_id
docker :image, :ls, *argumentize("--filter", "reference=#{config.latest_image}"), "--format", "'{{.ID}}'" docker :image, :ls, *argumentize("--filter", "reference=#{config.latest_image}"), "--format", "'{{.ID}}'"
end end

View File

@@ -28,4 +28,11 @@ module Kamal::Commands::App::Containers
container_id_for(container_name: container_name(version)), container_id_for(container_name: container_name(version)),
xargs(docker(:inspect, "--format", DOCKER_HEALTH_LOG_FORMAT)) xargs(docker(:inspect, "--format", DOCKER_HEALTH_LOG_FORMAT))
end end
def container_endpoint(version:)
pipe \
container_id_for(container_name: container_name(version)),
xargs(docker(:inspect, "--format", "'{{.NetworkSettings.IPAddress}}{{range $k, $v := .NetworkSettings.Ports}}{{printf \":%s\" $k}}{{break}}{{end}}'")),
[ :sed, "-e", "'s/\\/tcp$//'" ]
end
end end

View File

@@ -8,12 +8,9 @@ class Kamal::Commands::Auditor < Kamal::Commands::Base
# Runs remotely # Runs remotely
def record(line, **details) def record(line, **details)
combine \ append \
[ :mkdir, "-p", config.run_directory ], [ :echo, audit_tags(**details).except(:version, :service_version, :service).to_s, line ],
append( audit_log_file
[ :echo, audit_tags(**details).except(:version, :service_version, :service).to_s, line ],
audit_log_file
)
end end
def reveal def reveal

View File

@@ -37,10 +37,6 @@ module Kamal::Commands
[ :rm, "-r", path ] [ :rm, "-r", path ]
end end
def remove_file(path)
[ :rm, path ]
end
private private
def combine(*commands, by: "&&") def combine(*commands, by: "&&")
commands commands
@@ -85,10 +81,6 @@ module Kamal::Commands
[ :git, *([ "-C", path ] if path), *args.compact ] [ :git, *([ "-C", path ] if path), *args.compact ]
end end
def grep(*args)
args.compact.unshift :grep
end
def tags(**details) def tags(**details)
Kamal::Tags.from_config(config, **details) Kamal::Tags.from_config(config, **details)
end end

View File

@@ -1,8 +1,8 @@
require "active_support/core_ext/string/filters" require "active_support/core_ext/string/filters"
class Kamal::Commands::Builder < Kamal::Commands::Base class Kamal::Commands::Builder < Kamal::Commands::Base
delegate :create, :remove, :push, :clean, :pull, :info, :inspect_builder, :validate_image, :first_mirror, to: :target delegate :create, :remove, :push, :clean, :pull, :info, :context_hosts, :config_context_hosts, :validate_image,
delegate :local?, :remote?, to: "config.builder" :first_mirror, to: :target
include Clone include Clone
@@ -11,27 +11,43 @@ class Kamal::Commands::Builder < Kamal::Commands::Base
end end
def target def target
if remote? if config.builder.multiarch?
if local? if config.builder.remote?
hybrid if config.builder.local?
multiarch_remote
else
native_remote
end
else else
remote multiarch
end end
else else
local if config.builder.cached?
native_cached
else
native
end
end end
end end
def remote def native
@remote ||= Kamal::Commands::Builder::Remote.new(config) @native ||= Kamal::Commands::Builder::Native.new(config)
end end
def local def native_cached
@local ||= Kamal::Commands::Builder::Local.new(config) @native ||= Kamal::Commands::Builder::Native::Cached.new(config)
end end
def hybrid def native_remote
@hybrid ||= Kamal::Commands::Builder::Hybrid.new(config) @native ||= Kamal::Commands::Builder::Native::Remote.new(config)
end
def multiarch
@multiarch ||= Kamal::Commands::Builder::Multiarch.new(config)
end
def multiarch_remote
@multiarch_remote ||= Kamal::Commands::Builder::Multiarch::Remote.new(config)
end end

View File

@@ -1,41 +1,20 @@
class Kamal::Commands::Builder::Base < Kamal::Commands::Base class Kamal::Commands::Builder::Base < Kamal::Commands::Base
class BuilderError < StandardError; end class BuilderError < StandardError; end
ENDPOINT_DOCKER_HOST_INSPECT = "'{{.Endpoints.docker.Host}}'" ENDPOINT_DOCKER_HOST_INSPECT = "'{{.Endpoints.docker.Host}}'"
delegate :argumentize, to: Kamal::Utils delegate :argumentize, to: Kamal::Utils
delegate \ delegate :args, :secrets, :dockerfile, :target, :local_arch, :local_host, :remote_arch, :remote_host, :cache_from, :cache_to, :ssh, to: :builder_config
:args, :secrets, :dockerfile, :target, :arches, :local_arches, :remote_arches, :remote,
:cache_from, :cache_to, :ssh, :driver, :docker_driver?,
to: :builder_config
def clean def clean
docker :image, :rm, "--force", config.absolute_image docker :image, :rm, "--force", config.absolute_image
end end
def push
docker :buildx, :build,
"--push",
*platform_options(arches),
*([ "--builder", builder_name ] unless docker_driver?),
*build_options,
build_context
end
def pull def pull
docker :pull, config.absolute_image docker :pull, config.absolute_image
end end
def info
combine \
docker(:context, :ls),
docker(:buildx, :ls)
end
def inspect_builder
docker :buildx, :inspect, builder_name unless docker_driver?
end
def build_options def build_options
[ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh ] [ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh ]
end end
@@ -53,6 +32,14 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
) )
end end
def context_hosts
:true
end
def config_context_hosts
[]
end
def first_mirror def first_mirror
docker(:info, "--format '{{index .RegistryConfig.Mirrors 0}}'") docker(:info, "--format '{{index .RegistryConfig.Mirrors 0}}'")
end end
@@ -78,7 +65,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
end end
def build_secrets def build_secrets
argumentize "--secret", secrets.keys.collect { |secret| [ "id", secret ] } argumentize "--secret", secrets.collect { |secret| [ "id", secret ] }
end end
def build_dockerfile def build_dockerfile
@@ -101,7 +88,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
config.builder config.builder
end end
def platform_options(arches) def context_host(builder_name)
argumentize "--platform", arches.map { |arch| "linux/#{arch}" }.join(",") if arches.any? docker :context, :inspect, builder_name, "--format", ENDPOINT_DOCKER_HOST_INSPECT
end end
end end

View File

@@ -1,21 +0,0 @@
class Kamal::Commands::Builder::Hybrid < Kamal::Commands::Builder::Remote
def create
combine \
create_local_buildx,
create_remote_context,
append_remote_buildx
end
private
def builder_name
"kamal-hybrid-#{driver}-#{remote.gsub(/[^a-z0-9_-]/, "-")}"
end
def create_local_buildx
docker :buildx, :create, *platform_options(local_arches), "--name", builder_name, "--driver=#{driver}"
end
def append_remote_buildx
docker :buildx, :create, *platform_options(remote_arches), "--append", "--name", builder_name, remote_context_name
end
end

View File

@@ -1,14 +0,0 @@
class Kamal::Commands::Builder::Local < Kamal::Commands::Builder::Base
def create
docker :buildx, :create, "--name", builder_name, "--driver=#{driver}" unless docker_driver?
end
def remove
docker :buildx, :rm, builder_name unless docker_driver?
end
private
def builder_name
"kamal-local-#{driver}"
end
end

View File

@@ -0,0 +1,41 @@
class Kamal::Commands::Builder::Multiarch < Kamal::Commands::Builder::Base
def create
docker :buildx, :create, "--use", "--name", builder_name
end
def remove
docker :buildx, :rm, builder_name
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
def context_hosts
docker :buildx, :inspect, builder_name, "> /dev/null"
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

@@ -0,0 +1,61 @@
class Kamal::Commands::Builder::Multiarch::Remote < Kamal::Commands::Builder::Multiarch
def create
combine \
create_contexts,
create_local_buildx,
append_remote_buildx
end
def remove
combine \
remove_contexts,
super
end
def context_hosts
chain \
context_host(builder_name_with_arch(local_arch)),
context_host(builder_name_with_arch(remote_arch))
end
def config_context_hosts
[ local_host, remote_host ].compact
end
private
def builder_name
super + "-remote"
end
def builder_name_with_arch(arch)
"#{builder_name}-#{arch}"
end
def create_local_buildx
docker :buildx, :create, "--name", builder_name, builder_name_with_arch(local_arch), "--platform", "linux/#{local_arch}"
end
def append_remote_buildx
docker :buildx, :create, "--append", "--name", builder_name, builder_name_with_arch(remote_arch), "--platform", "linux/#{remote_arch}"
end
def create_contexts
combine \
create_context(local_arch, local_host),
create_context(remote_arch, remote_host)
end
def create_context(arch, host)
docker :context, :create, builder_name_with_arch(arch), "--description", "'#{builder_name} #{arch} native host'", "--docker", "'host=#{host}'"
end
def remove_contexts
combine \
remove_context(local_arch),
remove_context(remote_arch)
end
def remove_context(arch)
docker :context, :rm, builder_name_with_arch(arch)
end
end

View File

@@ -0,0 +1,20 @@
class Kamal::Commands::Builder::Native < Kamal::Commands::Builder::Base
def create
# No-op on native without cache
end
def remove
# 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
end

View File

@@ -0,0 +1,25 @@
class Kamal::Commands::Builder::Native::Cached < Kamal::Commands::Builder::Native
def create
docker :buildx, :create, "--name", builder_name, "--use", "--driver=docker-container"
end
def remove
docker :buildx, :rm, builder_name
end
def push
docker :buildx, :build,
"--push",
*build_options,
build_context
end
def context_hosts
docker :buildx, :inspect, builder_name, "> /dev/null"
end
private
def builder_name
"kamal-#{config.service}-native-cached"
end
end

View File

@@ -0,0 +1,67 @@
class Kamal::Commands::Builder::Native::Remote < Kamal::Commands::Builder::Native
def create
chain \
create_context,
create_buildx
end
def remove
chain \
remove_context,
remove_buildx
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
def context_hosts
context_host(builder_name_with_arch)
end
def config_context_hosts
[ remote_host ]
end
private
def builder_name
"kamal-#{config.service}-native-remote"
end
def builder_name_with_arch
"#{builder_name}-#{remote_arch}"
end
def platform
"linux/#{remote_arch}"
end
def create_context
docker :context, :create,
builder_name_with_arch, "--description", "'#{builder_name} #{remote_arch} native host'", "--docker", "'host=#{remote_host}'"
end
def remove_context
docker :context, :rm, builder_name_with_arch
end
def create_buildx
docker :buildx, :create, "--name", builder_name, builder_name_with_arch, "--platform", platform
end
def remove_buildx
docker :buildx, :rm, builder_name
end
end

View File

@@ -1,63 +0,0 @@
class Kamal::Commands::Builder::Remote < Kamal::Commands::Builder::Base
def create
chain \
create_remote_context,
create_buildx
end
def remove
chain \
remove_remote_context,
remove_buildx
end
def info
chain \
docker(:context, :ls),
docker(:buildx, :ls)
end
def inspect_builder
combine \
combine inspect_buildx, inspect_remote_context,
[ "(echo no compatible builder && exit 1)" ],
by: "||"
end
private
def builder_name
"kamal-remote-#{remote.gsub(/[^a-z0-9_-]/, "-")}"
end
def remote_context_name
"#{builder_name}-context"
end
def inspect_buildx
pipe \
docker(:buildx, :inspect, builder_name),
grep("-q", "Endpoint:.*#{remote_context_name}")
end
def inspect_remote_context
pipe \
docker(:context, :inspect, remote_context_name, "--format", ENDPOINT_DOCKER_HOST_INSPECT),
grep("-xq", remote)
end
def create_remote_context
docker :context, :create, remote_context_name, "--description", "'#{builder_name} host'", "--docker", "'host=#{remote}'"
end
def remove_remote_context
docker :context, :rm, remote_context_name
end
def create_buildx
docker :buildx, :create, "--name", builder_name, remote_context_name
end
def remove_buildx
docker :buildx, :rm, builder_name
end
end

View File

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

View File

@@ -1,9 +1,6 @@
class Kamal::Commands::Hook < Kamal::Commands::Base class Kamal::Commands::Hook < Kamal::Commands::Base
def run(hook, secrets: false, **details) def run(hook, **details)
env = tags(**details).env [ hook_file(hook), env: tags(**details).env ]
env.merge!(config.secrets.to_h) if secrets
[ hook_file(hook), env: env ]
end end
def hook_exists?(hook) def hook_exists?(hook)

View File

@@ -1,6 +1,6 @@
class Kamal::Commands::Proxy < Kamal::Commands::Base class Kamal::Commands::Proxy < Kamal::Commands::Base
delegate :argumentize, :optionize, to: Kamal::Utils delegate :argumentize, :optionize, to: Kamal::Utils
delegate :container_name, :app_port, to: :proxy_config delegate :container_name, to: :proxy_config
attr_reader :proxy_config attr_reader :proxy_config
@@ -12,12 +12,11 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base
def run def run
docker :run, docker :run,
"--name", container_name, "--name", container_name,
"--network", "kamal",
"--detach", "--detach",
"--restart", "unless-stopped", "--restart", "unless-stopped",
*proxy_config.publish_args, *proxy_config.publish_args,
"--volume", "/var/run/docker.sock:/var/run/docker.sock", "--volume", "/var/run/docker.sock:/var/run/docker.sock",
"--volume", "#{proxy_config.config_directory_as_docker_volume}:/root/.config/kamal-proxy", "--volume", "#{container_name}:/root/.config/kamal-proxy",
*config.logging_args, *config.logging_args,
proxy_config.image proxy_config.image
end end
@@ -35,11 +34,11 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base
end end
def deploy(service, target:) def deploy(service, target:)
docker :exec, container_name, "kamal-proxy", :deploy, service, *optionize({ target: "#{target}:#{app_port}" }), *proxy_config.deploy_command_args docker :exec, container_name, "kamal-proxy", :deploy, service, *optionize({ target: target }), *proxy_config.deploy_command_args
end end
def remove(service, target:) def remove(service, target:)
docker :exec, container_name, "kamal-proxy", :remove, service, *optionize({ target: "#{target}:#{app_port}" }) docker :exec, container_name, "kamal-proxy", :remove, service, *optionize({ target: target })
end end
def info def info

View File

@@ -9,7 +9,7 @@ class Kamal::Commands::Prune < Kamal::Commands::Base
def tagged_images def tagged_images
pipe \ pipe \
docker(:image, :ls, *service_filter, "--format", "'{{.ID}} {{.Repository}}:{{.Tag}}'"), docker(:image, :ls, *service_filter, "--format", "'{{.ID}} {{.Repository}}:{{.Tag}}'"),
grep("-v -w \"#{active_image_list}\""), "grep -v -w \"#{active_image_list}\"",
"while read image tag; do docker rmi $tag; done" "while read image tag; do docker rmi $tag; done"
end end

View File

@@ -1,6 +1,6 @@
class Kamal::Commands::Traefik < Kamal::Commands::Base class Kamal::Commands::Traefik < Kamal::Commands::Base
delegate :argumentize, :optionize, to: Kamal::Utils delegate :argumentize, :optionize, to: Kamal::Utils
delegate :port, :publish?, :labels, :env, :image, :options, :args, :env_args, :secrets_io, :env_directory, :secrets_path, to: :"config.traefik" delegate :port, :publish?, :labels, :env, :image, :options, :args, to: :"config.traefik"
def run def run
docker :run, "--name traefik", docker :run, "--name traefik",
@@ -54,8 +54,12 @@ class Kamal::Commands::Traefik < Kamal::Commands::Base
docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik" docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik"
end end
def ensure_env_directory def make_env_directory
make_directory env_directory make_directory(env.secrets_directory)
end
def remove_env_file
[ :rm, "-f", env.secrets_file ]
end end
private private
@@ -67,6 +71,10 @@ class Kamal::Commands::Traefik < Kamal::Commands::Base
argumentize "--label", labels argumentize "--label", labels
end end
def env_args
env.args
end
def docker_options_args def docker_options_args
optionize(options) optionize(options)
end end

View File

@@ -2,6 +2,7 @@ require "active_support/ordered_options"
require "active_support/core_ext/string/inquiry" require "active_support/core_ext/string/inquiry"
require "active_support/core_ext/module/delegation" require "active_support/core_ext/module/delegation"
require "active_support/core_ext/hash/keys" require "active_support/core_ext/hash/keys"
require "pathname"
require "erb" require "erb"
require "net/ssh/proxy/jump" require "net/ssh/proxy/jump"
@@ -56,7 +57,7 @@ class Kamal::Configuration
@aliases = @raw_config.aliases&.keys&.to_h { |name| [ name, Alias.new(name, config: self) ] } || {} @aliases = @raw_config.aliases&.keys&.to_h { |name| [ name, Alias.new(name, config: self) ] } || {}
@boot = Boot.new(config: self) @boot = Boot.new(config: self)
@builder = Builder.new(config: self) @builder = Builder.new(config: self)
@env = Env.new(config: @raw_config.env || {}, secrets: secrets) @env = Env.new(config: @raw_config.env || {})
@healthcheck = Healthcheck.new(healthcheck_config: @raw_config.healthcheck) @healthcheck = Healthcheck.new(healthcheck_config: @raw_config.healthcheck)
@logging = Logging.new(logging_config: @raw_config.logging) @logging = Logging.new(logging_config: @raw_config.logging)
@@ -202,11 +203,15 @@ class Kamal::Configuration
def run_directory def run_directory
".kamal" raw_config.run_directory || ".kamal"
end end
def run_directory_as_docker_volume def run_directory_as_docker_volume
File.join "$(pwd)", run_directory if Pathname.new(run_directory).absolute?
run_directory
else
File.join "$(pwd)", run_directory
end
end end
def hooks_path def hooks_path
@@ -218,13 +223,13 @@ class Kamal::Configuration
end end
def env_directory def host_env_directory
File.join(run_directory, "env") File.join(run_directory, "env")
end end
def env_tags def env_tags
@env_tags ||= if (tags = raw_config.env["tags"]) @env_tags ||= if (tags = raw_config.env["tags"])
tags.collect { |name, config| Env::Tag.new(name, config: config, secrets: secrets) } tags.collect { |name, config| Env::Tag.new(name, config: config) }
else else
[] []
end end
@@ -254,10 +259,6 @@ class Kamal::Configuration
}.compact }.compact
end end
def secrets
@secrets ||= Kamal::Secrets.new(destination: destination)
end
private private
# Will raise ArgumentError if any required config keys are missing # Will raise ArgumentError if any required config keys are missing
def ensure_destination_if_required def ensure_destination_if_required

View File

@@ -16,7 +16,7 @@ class Kamal::Configuration::Accessory
@env = Kamal::Configuration::Env.new \ @env = Kamal::Configuration::Env.new \
config: accessory_config.fetch("env", {}), config: accessory_config.fetch("env", {}),
secrets: config.secrets, secrets_file: File.join(config.host_env_directory, "accessories", "#{service_name}.env"),
context: "accessories/#{name}/env" context: "accessories/#{name}/env"
end end
@@ -51,19 +51,7 @@ class Kamal::Configuration::Accessory
end end
def env_args def env_args
[ *env.clear_args, *argumentize("--env-file", secrets_path) ] env.args
end
def env_directory
File.join(config.env_directory, "accessories")
end
def secrets_io
env.secrets_io
end
def secrets_path
File.join(config.env_directory, "accessories", "#{service_name}.env")
end end
def files def files

View File

@@ -19,38 +19,16 @@ class Kamal::Configuration::Builder
builder_config builder_config
end end
def remote def multiarch?
builder_config["remote"] builder_config["multiarch"] != false
end
def arches
Array(builder_config.fetch("arch", default_arch))
end
def local_arches
@local_arches ||= if local_disabled?
[]
elsif remote
arches & [ Kamal::Utils.docker_arch ]
else
arches
end
end
def remote_arches
@remote_arches ||= if remote
arches - local_arches
else
[]
end
end
def remote?
remote_arches.any?
end end
def local? def local?
!local_disabled? && (arches.empty? || local_arches.any?) !!builder_config["local"]
end
def remote?
!!builder_config["remote"]
end end
def cached? def cached?
@@ -62,7 +40,7 @@ class Kamal::Configuration::Builder
end end
def secrets def secrets
(builder_config["secrets"] || []).to_h { |key| [ key, config.secrets[key] ] } builder_config["secrets"] || []
end end
def dockerfile def dockerfile
@@ -77,12 +55,20 @@ class Kamal::Configuration::Builder
builder_config["context"] || "." builder_config["context"] || "."
end end
def driver def local_arch
builder_config.fetch("driver", "docker-container") builder_config["local"]["arch"] if local?
end end
def local_disabled? def local_host
builder_config["local"] == false builder_config["local"]["host"] if local?
end
def remote_arch
builder_config["remote"]["arch"] if remote?
end
def remote_host
builder_config["remote"]["host"] if remote?
end end
def cache_from def cache_from
@@ -128,23 +114,7 @@ class Kamal::Configuration::Builder
end end
end end
def docker_driver?
driver == "docker"
end
private private
def valid?
if docker_driver?
raise ArgumentError, "Invalid builder configuration: the `docker` driver does not not support remote builders" if remote
raise ArgumentError, "Invalid builder configuration: the `docker` driver does not not support caching" if cached?
raise ArgumentError, "Invalid builder configuration: the `docker` driver does not not support multiple arches" if arches.many?
end
if @options["cache"] && @options["cache"]["type"]
raise ArgumentError, "Invalid cache type: #{@options["cache"]["type"]}" unless [ "gha", "registry" ].include?(@options["cache"]["type"])
end
end
def cache_image def cache_image
builder_config["cache"]&.fetch("image", nil) || "#{image}-build-cache" builder_config["cache"]&.fetch("image", nil) || "#{image}-build-cache"
end end
@@ -180,8 +150,4 @@ class Kamal::Configuration::Builder
def pwd_sha def pwd_sha
Digest::SHA256.hexdigest(Dir.pwd)[0..12] Digest::SHA256.hexdigest(Dir.pwd)[0..12]
end end
def default_arch
docker_driver? ? [] : [ "amd64", "arm64" ]
end
end end

View File

@@ -1,10 +1,10 @@
# Builder # Builder
# #
# The builder configuration controls how the application is built with `docker build` # The builder configuration controls how the application is built with `docker build` or `docker buildx build`
# #
# If no configuration is specified, Kamal will: # If no configuration is specified, Kamal will:
# 1. Create a buildx context called `kamal-local-docker-container`, using the docker-container driver # 1. Create a buildx context called `kamal-<service>-multiarch`
# 2. Use `docker build` to build a multiarch image for linux/amd64,linux/arm64 with that context # 2. Use `docker buildx build` to build a multiarch image for linux/amd64,linux/arm64 with that context
# #
# See https://kamal-deploy.org/docs/configuration/builder-examples/ for more information # See https://kamal-deploy.org/docs/configuration/builder-examples/ for more information
@@ -12,34 +12,36 @@
# #
# Options go under the builder key in the root configuration. # Options go under the builder key in the root configuration.
builder: builder:
# Driver
#
# The build driver to use, defaults to `docker-container`
driver: docker
# Arch # Multiarch
# #
# The architectures to build for, defaults to `[ amd64, arm64 ]` # Enables multiarch builds, defaults to `true`
# Unless you are using the docker driver, when it defaults to the local architecture multiarch: false
# You can set an array or just a single value
arch: # Local configuration
- amd64 #
# The build configuration for local builds, only used if multiarch is enabled (the default)
#
# If there is no remote configuration, by default we build for amd64 and arm64.
# If you only want to build for one architecture, you can specify it here.
# The docker socket is optional and uses the default docker host socket when not specified
local:
arch: amd64
host: /var/run/docker.sock
# Remote configuration # Remote configuration
# #
# If you have a remote builder, you can configure it here # The build configuration for remote builds, also only used if multiarch is enabled.
remote: ssh://docker@docker-builder # The arch is required and can be either amd64 or arm64.
remote:
# Whether to allow local builds arch: arm64
# host: ssh://docker@docker-builder
# Defaults to true
local: true
# Builder cache # Builder cache
# #
# The type must be either 'gha' or 'registry' # The type must be either 'gha' or 'registry'
# #
# The image is only used for registry cache. Not compatible with the docker driver # The image is only used for registry cache
cache: cache:
type: registry type: registry
options: mode=max options: mode=max

View File

@@ -1,7 +1,7 @@
# Environment variables # Environment variables
# #
# Environment variables can be set directly in the Kamal configuration or # Environment variables can be set directory in the Kamal configuration or
# loaded from a .env file, for secrets that should not be checked into Git. # for loaded from a .env file, for secrets that should not be checked into Git.
# Reading environment variables from the configuration # Reading environment variables from the configuration
# #
@@ -24,12 +24,14 @@ env:
# KAMAL_REGISTRY_PASSWORD=pw # KAMAL_REGISTRY_PASSWORD=pw
# DB_PASSWORD=secret123 # DB_PASSWORD=secret123
# ``` # ```
# See https://kamal-deploy.org/docs/commands/envify/ for how to use generated .env files.
# #
# To pass the secrets you should list them under the `secret` key. When you do this the # To pass the secrets you should list them under the `secret` key. When you do this the
# other variables need to be moved under the `clear` key. # other variables need to be moved under the `clear` key.
# #
# Unlike clear values, secrets are not passed directly to the container, # Unlike clear values, secrets are not passed directly to the container,
# but are stored in an env file on the host # but are stored in an env file on the host
# The file is not updated when deploying, only when running `kamal envify` or `kamal env push`.
env: env:
clear: clear:
DB_USER: app DB_USER: app

View File

@@ -3,7 +3,7 @@
# On roles that are running Traefik, Kamal will supply a default healthcheck to `docker run`. # On roles that are running Traefik, Kamal will supply a default healthcheck to `docker run`.
# For other roles, by default no healthcheck is supplied. # For other roles, by default no healthcheck is supplied.
# #
# If no healthcheck is supplied and the image does not define one, then we wait for the container # If no healthcheck is supplied and the image does not define one, they we wait for the container
# to reach a running state and then pause for the readiness delay. # to reach a running state and then pause for the readiness delay.
# #
# The default healthcheck is `curl -f http://localhost:<port>/<path>`, so it assumes that `curl` # The default healthcheck is `curl -f http://localhost:<port>/<path>`, so it assumes that `curl`

View File

@@ -56,19 +56,6 @@ proxy:
# requests for other apps that do have a host set. # requests for other apps that do have a host set.
host: foo.example.com host: foo.example.com
# App port
#
# The port the application container is exposed on
# Defaults to 80
app_port: 3000
# SSL
#
# Kamal Proxy can automatically obtain and renew TLS certificates for your applications.
# To ensure this set, the ssl flag. This only works if we are deploying to one server and
# the host flag is set.
ssl: true
# Deploy timeout # Deploy timeout
# #
# How long to wait for the app to boot when deploying, defaults to 30 seconds # How long to wait for the app to boot when deploying, defaults to 30 seconds

View File

@@ -1,29 +1,36 @@
class Kamal::Configuration::Env class Kamal::Configuration::Env
include Kamal::Configuration::Validation include Kamal::Configuration::Validation
attr_reader :context, :secrets attr_reader :secrets_keys, :clear, :secrets_file, :context
attr_reader :clear, :secret_keys
delegate :argumentize, to: Kamal::Utils delegate :argumentize, to: Kamal::Utils
def initialize(config:, secrets:, context: "env") def initialize(config:, secrets_file: nil, context: "env")
@clear = config.fetch("clear", config.key?("secret") || config.key?("tags") ? {} : config) @clear = config.fetch("clear", config.key?("secret") || config.key?("tags") ? {} : config)
@secrets = secrets @secrets_keys = config.fetch("secret", [])
@secret_keys = config.fetch("secret", []) @secrets_file = secrets_file
@context = context @context = context
validate! config, context: context, with: Kamal::Configuration::Validator::Env validate! config, context: context, with: Kamal::Configuration::Validator::Env
end end
def clear_args def args
argumentize("--env", clear) [ "--env-file", secrets_file, *argumentize("--env", clear) ]
end end
def secrets_io def secrets_io
Kamal::EnvFile.new(secret_keys.to_h { |key| [ key, secrets[key] ] }).to_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 end
def merge(other) def merge(other)
self.class.new \ self.class.new \
config: { "clear" => clear.merge(other.clear), "secret" => secret_keys | other.secret_keys }, config: { "clear" => clear.merge(other.clear), "secret" => secrets_keys | other.secrets_keys },
secrets: secrets secrets_file: secrets_file || other.secrets_file
end end
end end

View File

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

View File

@@ -9,9 +9,8 @@ class Kamal::Configuration::Proxy
delegate :argumentize, :optionize, to: Kamal::Utils delegate :argumentize, :optionize, to: Kamal::Utils
def initialize(config:) def initialize(config:)
@config = config
@proxy_config = config.raw_config.proxy || {} @proxy_config = config.raw_config.proxy || {}
validate! proxy_config, with: Kamal::Configuration::Validator::Proxy validate! proxy_config
end end
def enabled? def enabled?
@@ -26,10 +25,6 @@ class Kamal::Configuration::Proxy
end end
end end
def app_port
proxy_config.fetch("app_port", 80)
end
def image def image
proxy_config.fetch("image", DEFAULT_IMAGE) proxy_config.fetch("image", DEFAULT_IMAGE)
end end
@@ -42,14 +37,9 @@ class Kamal::Configuration::Proxy
argumentize "--publish", [ "#{DEFAULT_HTTP_PORT}:#{DEFAULT_HTTP_PORT}", "#{DEFAULT_HTTPS_PORT}:#{DEFAULT_HTTPS_PORT}" ] argumentize "--publish", [ "#{DEFAULT_HTTP_PORT}:#{DEFAULT_HTTP_PORT}", "#{DEFAULT_HTTPS_PORT}:#{DEFAULT_HTTPS_PORT}" ]
end end
def ssl?
proxy_config.fetch("ssl", false)
end
def deploy_options def deploy_options
{ {
host: proxy_config["host"], host: proxy_config["host"],
tls: proxy_config["ssl"],
"deploy-timeout": proxy_config["deploy_timeout"], "deploy-timeout": proxy_config["deploy_timeout"],
"drain-timeout": proxy_config["drain_timeout"], "drain-timeout": proxy_config["drain_timeout"],
"health-check-interval": proxy_config.dig("health_check", "interval"), "health-check-interval": proxy_config.dig("health_check", "interval"),
@@ -71,10 +61,6 @@ class Kamal::Configuration::Proxy
optionize deploy_options optionize deploy_options
end end
def config_directory_as_docker_volume
File.join config.run_directory_as_docker_volume, "proxy", "config"
end
private private
attr_reader :config, :proxy_config attr_accessor :proxy_config
end end

View File

@@ -1,11 +1,10 @@
class Kamal::Configuration::Registry class Kamal::Configuration::Registry
include Kamal::Configuration::Validation include Kamal::Configuration::Validation
attr_reader :registry_config, :secrets attr_reader :registry_config
def initialize(config:) def initialize(config:)
@registry_config = config.raw_config.registry || {} @registry_config = config.raw_config.registry || {}
@secrets = config.secrets
validate! registry_config, with: Kamal::Configuration::Validator::Registry validate! registry_config, with: Kamal::Configuration::Validator::Registry
end end
@@ -24,7 +23,7 @@ class Kamal::Configuration::Registry
private private
def lookup(key) def lookup(key)
if registry_config[key].is_a?(Array) if registry_config[key].is_a?(Array)
secrets[registry_config[key].first] ENV.fetch(registry_config[key].first).dup
else else
registry_config[key] registry_config[key]
end end

View File

@@ -18,7 +18,7 @@ class Kamal::Configuration::Role
@specialized_env = Kamal::Configuration::Env.new \ @specialized_env = Kamal::Configuration::Env.new \
config: specializations.fetch("env", {}), config: specializations.fetch("env", {}),
secrets: config.secrets, secrets_file: File.join(config.host_env_directory, "roles", "#{container_prefix}.env"),
context: "servers/#{name}/env" context: "servers/#{name}/env"
@specialized_logging = Kamal::Configuration::Logging.new \ @specialized_logging = Kamal::Configuration::Logging.new \
@@ -85,19 +85,7 @@ class Kamal::Configuration::Role
end end
def env_args(host) def env_args(host)
[ *env(host).clear_args, *argumentize("--env-file", secrets_path) ] env(host).args
end
def env_directory
File.join(config.env_directory, "roles")
end
def secrets_io(host)
env(host).secrets_io
end
def secrets_path
File.join(config.env_directory, "roles", "#{container_prefix}.env")
end end
def asset_volume_args def asset_volume_args

View File

@@ -1,6 +1,4 @@
class Kamal::Configuration::Traefik class Kamal::Configuration::Traefik
delegate :argumentize, to: Kamal::Utils
DEFAULT_IMAGE = "traefik:v2.10" DEFAULT_IMAGE = "traefik:v2.10"
CONTAINER_PORT = 80 CONTAINER_PORT = 80
DEFAULT_ARGS = { DEFAULT_ARGS = {
@@ -36,7 +34,7 @@ class Kamal::Configuration::Traefik
def env def env
Kamal::Configuration::Env.new \ Kamal::Configuration::Env.new \
config: traefik_config.fetch("env", {}), config: traefik_config.fetch("env", {}),
secrets: config.secrets, secrets_file: File.join(config.host_env_directory, "traefik", "traefik.env"),
context: "traefik/env" context: "traefik/env"
end end
@@ -59,20 +57,4 @@ class Kamal::Configuration::Traefik
def image def image
traefik_config.fetch("image", DEFAULT_IMAGE) traefik_config.fetch("image", DEFAULT_IMAGE)
end end
def env_args
[ *env.clear_args, *argumentize("--env-file", secrets_path) ]
end
def env_directory
File.join(config.env_directory, "traefik")
end
def secrets_io
env.secrets_io
end
def secrets_path
File.join(config.env_directory, "traefik", "traefik.env")
end
end end

View File

@@ -28,11 +28,7 @@ class Kamal::Configuration::Validator
elsif key == "hosts" elsif key == "hosts"
validate_servers! value validate_servers! value
elsif example_value.is_a?(Array) elsif example_value.is_a?(Array)
if key == "arch" validate_array_of! value, example_value.first.class
validate_array_of_or_type! value, example_value.first.class
else
validate_array_of! value, example_value.first.class
end
elsif example_value.is_a?(Hash) elsif example_value.is_a?(Hash)
case key.to_s case key.to_s
when "options", "args" when "options", "args"
@@ -75,16 +71,6 @@ class Kamal::Configuration::Validator
value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Numeric) || value.is_a?(TrueClass) || value.is_a?(FalseClass) value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Numeric) || value.is_a?(TrueClass) || value.is_a?(FalseClass)
end end
def validate_array_of_or_type!(value, type)
if value.is_a?(Array)
validate_array_of! value, type
else
validate_type! value, type
end
rescue Kamal::ConfigurationError
type_error(Array, type)
end
def validate_array_of!(array, type) def validate_array_of!(array, type)
validate_type! array, Array validate_type! array, Array

View File

@@ -5,9 +5,5 @@ class Kamal::Configuration::Validator::Builder < Kamal::Configuration::Validator
if config["cache"] && config["cache"]["type"] if config["cache"] && config["cache"]["type"]
error "Invalid cache type: #{config["cache"]["type"]}" unless [ "gha", "registry" ].include?(config["cache"]["type"]) error "Invalid cache type: #{config["cache"]["type"]}" unless [ "gha", "registry" ].include?(config["cache"]["type"])
end end
error "Builder arch not set" unless config["arch"].present?
error "Cannot disable local builds, no remote is set" if config["local"] == false && config["remote"].blank?
end end
end end

View File

@@ -1,9 +0,0 @@
class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
def validate!
super
if config["host"].blank? && config["ssl"]
error "Must set a host to enable automatic SSL"
end
end
end

View File

@@ -15,10 +15,6 @@ class Kamal::EnvFile
env_file.presence || "\n" env_file.presence || "\n"
end end
def to_io
StringIO.new(to_s)
end
alias to_str to_s alias to_str to_s
private private

View File

@@ -1,33 +0,0 @@
require "dotenv"
class Kamal::Secrets
attr_reader :secrets_files
Kamal::Secrets::Dotenv::InlineCommandSubstitution.install!
def initialize(destination: nil)
@secrets_files = \
[ ".kamal/secrets-common", ".kamal/secrets#{(".#{destination}" if destination)}" ].select { |f| File.exist?(f) }
end
def [](key)
secrets.fetch(key)
rescue KeyError
if secrets_files
raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secrets_files.join(", ")}"
else
raise Kamal::ConfigurationError, "Secret '#{key}' not found, no secret files provided"
end
end
def to_h
secrets
end
private
def secrets
@secrets ||= secrets_files.inject({}) do |secrets, secrets_file|
secrets.merge!(::Dotenv.parse(secrets_file))
end
end
end

View File

@@ -1,14 +0,0 @@
require "active_support/core_ext/string/inflections"
module Kamal::Secrets::Adapters
def self.lookup(name)
name = "one_password" if name.downcase == "1password"
name = "last_pass" if name.downcase == "lastpass"
adapter_class(name)
end
def self.adapter_class(name)
Object.const_get("Kamal::Secrets::Adapters::#{name.camelize}").new
rescue NameError => e
raise RuntimeError, "Unknown secrets adapter: #{name}"
end
end

View File

@@ -1,18 +0,0 @@
class Kamal::Secrets::Adapters::Base
delegate :optionize, to: Kamal::Utils
def fetch(secrets, account:, from: nil)
session = login(account)
full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") }
fetch_secrets(full_secrets, account: account, session: session)
end
private
def login(...)
raise NotImplementedError
end
def fetch_secrets(...)
raise NotImplementedError
end
end

View File

@@ -1,64 +0,0 @@
class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base
private
def login(account)
status = run_command("status")
if status["status"] == "unauthenticated"
run_command("login #{account.shellescape}", raw: true)
status = run_command("status")
end
if status["status"] == "locked"
session = run_command("unlock --raw", raw: true).presence
status = run_command("status", session: session)
end
raise RuntimeError, "Failed to login to and unlock Bitwarden" unless status["status"] == "unlocked"
run_command("sync", session: session, raw: true)
raise RuntimeError, "Failed to sync Bitwarden" unless $?.success?
session
end
def fetch_secrets(secrets, account:, session:)
{}.tap do |results|
items_fields(secrets).each do |item, fields|
item_json = run_command("get item #{item.shellescape}", session: session, raw: true)
raise RuntimeError, "Could not read #{secret} from Bitwarden" unless $?.success?
item_json = JSON.parse(item_json)
if fields.any?
fields.each do |field|
item_field = item_json["fields"].find { |f| f["name"] == field }
raise RuntimeError, "Could not find field #{field} in item #{item} in Bitwarden" unless item_field
value = item_field["value"]
results["#{item}/#{field}"] = value
end
else
results[item] = item_json["login"]["password"]
end
end
end
end
def items_fields(secrets)
{}.tap do |items|
secrets.each do |secret|
item, field = secret.split("/")
items[item] ||= []
items[item] << field
end
end
end
def signedin?(account)
run_command("status")["status"] != "unauthenticated"
end
def run_command(command, session: nil, raw: false)
full_command = [ *("BW_SESSION=#{session.shellescape}" if session), "bw", command ].join(" ")
result = `#{full_command}`.strip
raw ? result : JSON.parse(result)
end
end

View File

@@ -1,30 +0,0 @@
class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base
private
def login(account)
unless loggedin?(account)
`lpass login #{account.shellescape}`
raise RuntimeError, "Failed to login to 1Password" unless $?.success?
end
end
def loggedin?(account)
`lpass status --color never`.strip == "Logged in as #{account}."
end
def fetch_secrets(secrets, account:, session:)
items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json`
raise RuntimeError, "Could not read #{secrets} from 1Password" unless $?.success?
items = JSON.parse(items)
{}.tap do |results|
items.each do |item|
results[item["fullname"]] = item["password"]
end
if (missing_items = secrets - results.keys).any?
raise RuntimeError, "Could not find #{missing_items.join(", ")} in LassPass"
end
end
end
end

View File

@@ -1,61 +0,0 @@
class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base
delegate :optionize, to: Kamal::Utils
private
def login(account)
unless loggedin?(account)
`op signin #{to_options(account: account, force: true, raw: true)}`.tap do
raise RuntimeError, "Failed to login to 1Password" unless $?.success?
end
end
end
def loggedin?(account)
`op account get --account #{account.shellescape} 2> /dev/null`
$?.success?
end
def fetch_secrets(secrets, account:, session:)
{}.tap do |results|
vaults_items_fields(secrets).map do |vault, items|
items.each do |item, fields|
fields_json = JSON.parse(op_item_get(vault, item, fields, account: account, session: session))
fields_json = [ fields_json ] if fields.one?
fields_json.each do |field_json|
# The reference is in the form `op://vault/item/field[/field]`
field = field_json["reference"].delete_prefix("op://").delete_suffix("/password")
results[field] = field_json["value"]
end
end
end
end
end
def to_options(**options)
optionize(options.compact).join(" ")
end
def vaults_items_fields(secrets)
{}.tap do |vaults|
secrets.each do |secret|
secret = secret.delete_prefix("op://")
vault, item, *fields = secret.split("/")
fields << "password" if fields.empty?
vaults[vault] ||= {}
vaults[vault][item] ||= []
vaults[vault][item] << fields.join(".")
end
end
end
def op_item_get(vault, item, fields, account:, session:)
labels = fields.map { |field| "label=#{field}" }.join(",")
options = to_options(vault: vault, fields: labels, format: "json", account: account, session: session.presence)
`op item get #{item.shellescape} #{options}`.tap do
raise RuntimeError, "Could not read #{fields.join(", ")} from #{item} in the #{vault} 1Password vault" unless $?.success?
end
end
end

View File

@@ -1,10 +0,0 @@
class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base
private
def login(account)
true
end
def fetch_secrets(secrets, account:, session:)
secrets.to_h { |secret| [ secret, secret.reverse ] }
end
end

View File

@@ -1,32 +0,0 @@
class Kamal::Secrets::Dotenv::InlineCommandSubstitution
class << self
def install!
::Dotenv::Parser.substitutions.map! { |sub| sub == ::Dotenv::Substitutions::Command ? self : sub }
end
def call(value, _env, overwrite: false)
# Process interpolated shell commands
value.gsub(Dotenv::Substitutions::Command.singleton_class::INTERPOLATED_SHELL_COMMAND) do |*|
# Eliminate opening and closing parentheses
command = $LAST_MATCH_INFO[:cmd][1..-2]
if $LAST_MATCH_INFO[:backslash]
# Command is escaped, don't replace it.
$LAST_MATCH_INFO[0][1..]
else
if command =~ /\A\s*kamal\s*secrets\s+/
# Inline the command
inline_secrets_command(command)
else
# Execute the command and return the value
`#{command}`.chomp
end
end
end
end
def inline_secrets_command(command)
Kamal::Cli::Main.start(command.shellsplit[1..] + [ "--inline" ]).chomp
end
end
end

View File

@@ -3,7 +3,6 @@ require "sshkit/dsl"
require "net/scp" require "net/scp"
require "active_support/core_ext/hash/deep_merge" require "active_support/core_ext/hash/deep_merge"
require "json" require "json"
require "concurrent/atomic/semaphore"
class SSHKit::Backend::Abstract class SSHKit::Backend::Abstract
def capture_with_info(*args, **kwargs) def capture_with_info(*args, **kwargs)

View File

@@ -1,5 +1,3 @@
require "active_support/core_ext/object/try"
module Kamal::Utils module Kamal::Utils
extend self extend self
@@ -56,12 +54,6 @@ module Kamal::Utils
# Escape a value to make it safe for shell use. # Escape a value to make it safe for shell use.
def escape_shell_value(value) def escape_shell_value(value)
value.to_s.scan(/[\x00-\x7F]+|[^\x00-\x7F]+/) \
.map { |part| part.ascii_only? ? escape_ascii_shell_value(part) : part }
.join
end
def escape_ascii_shell_value(value)
value.to_s.dump value.to_s.dump
.gsub(/`/, '\\\\`') .gsub(/`/, '\\\\`')
.gsub(DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX, '\$') .gsub(DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX, '\$')
@@ -89,16 +81,4 @@ module Kamal::Utils
def join_commands(commands) def join_commands(commands)
commands.map(&:strip).join(" ") commands.map(&:strip).join(" ")
end end
def docker_arch
arch = `docker info --format '{{.Architecture}}'`.strip
case arch
when /aarch64/
"arm64"
when /x86_64/
"amd64"
else
arch
end
end
end end

View File

@@ -1,21 +1,13 @@
require_relative "cli_test_case" require_relative "cli_test_case"
class CliAccessoryTest < CliTestCase class CliAccessoryTest < CliTestCase
setup do
setup_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret")
end
teardown do
teardown_test_secrets
end
test "boot" do test "boot" do
Kamal::Cli::Accessory.any_instance.expects(:directories).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:directories).with("mysql")
Kamal::Cli::Accessory.any_instance.expects(:upload).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:upload).with("mysql")
run_command("boot", "mysql").tap do |output| run_command("boot", "mysql").tap do |output|
assert_match /docker login.*on 1.1.1.3/, output assert_match /docker login.*on 1.1.1.3/, output
assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --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-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
end end
end end
@@ -29,12 +21,9 @@ class CliAccessoryTest < CliTestCase
assert_match /docker login.*on 1.1.1.3/, output 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.1/, output
assert_match /docker login.*on 1.1.1.2/, output assert_match /docker login.*on 1.1.1.2/, output
assert_match /docker network create kamal.*on 1.1.1.1/, 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 network create kamal.*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_match /docker network create kamal.*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.2", output
assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --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 --network kamal --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 --network kamal --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
end end
@@ -54,7 +43,7 @@ class CliAccessoryTest < CliTestCase
Kamal::Commands::Registry.any_instance.expects(:login) Kamal::Commands::Registry.any_instance.expects(:login)
Kamal::Cli::Accessory.any_instance.expects(:stop).with("mysql") 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(:remove_container).with("mysql")
Kamal::Cli::Accessory.any_instance.expects(:boot).with("mysql", prepare: false) Kamal::Cli::Accessory.any_instance.expects(:boot).with("mysql", login: false)
run_command("reboot", "mysql") run_command("reboot", "mysql")
end end
@@ -63,10 +52,10 @@ class CliAccessoryTest < CliTestCase
Kamal::Commands::Registry.any_instance.expects(:login).times(3) Kamal::Commands::Registry.any_instance.expects(:login).times(3)
Kamal::Cli::Accessory.any_instance.expects(:stop).with("mysql") 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(:remove_container).with("mysql")
Kamal::Cli::Accessory.any_instance.expects(:boot).with("mysql", prepare: false) 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(:stop).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("redis") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:boot).with("redis", prepare: false) Kamal::Cli::Accessory.any_instance.expects(:boot).with("redis", login: false)
run_command("reboot", "all") run_command("reboot", "all")
end end
@@ -203,8 +192,8 @@ class CliAccessoryTest < CliTestCase
run_command("boot", "redis", "--hosts", "1.1.1.1").tap do |output| run_command("boot", "redis", "--hosts", "1.1.1.1").tap do |output|
assert_match /docker login.*on 1.1.1.1/, output assert_match /docker login.*on 1.1.1.1/, output
assert_no_match /docker login.*on 1.1.1.2/, output assert_no_match /docker login.*on 1.1.1.2/, output
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --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.1", output
assert_no_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --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 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
end end
@@ -215,8 +204,8 @@ class CliAccessoryTest < CliTestCase
run_command("boot", "redis", "--hosts", "1.1.1.1,1.1.1.3").tap do |output| 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_match /docker login.*on 1.1.1.1/, output
assert_no_match /docker login.*on 1.1.1.3/, output assert_no_match /docker login.*on 1.1.1.3/, output
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --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.1", output
assert_no_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --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 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
end end

View File

@@ -113,7 +113,7 @@ class CliAppTest < CliTestCase
run_command("boot", config: :with_env_tags).tap do |output| run_command("boot", config: :with_env_tags).tap do |output|
assert_match "docker tag dhh/app:latest dhh/app:latest", output assert_match "docker tag dhh/app:latest dhh/app:latest", output
assert_match %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 TEST="root" --env EXPERIMENT="disabled" --env SITE="site1"}, 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 assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
end end
end end
@@ -362,8 +362,8 @@ class CliAppTest < CliTestCase
run_command("boot", config: :with_proxy).tap do |output| run_command("boot", config: :with_proxy).tap do |output|
assert_match /Renaming container .* to .* as already deployed on 1.1.1.1/, output # Rename assert_match /Renaming container .* to .* as already deployed on 1.1.1.1/, output # Rename
assert_match /docker rename app-web-latest app-web-latest_replaced_[0-9a-f]{16}/, output assert_match /docker rename app-web-latest app-web-latest_replaced_[0-9a-f]{16}/, output
assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --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 --log-opt max-size="10m" --label service="app" --label role="web" --label destination dhh\/app:latest/, output assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname 1.1.1.1-[0-9a-f]{12} -e KAMAL_CONTAINER_NAME="app-web-latest" -e KAMAL_VERSION="latest" --env-file .kamal\/env\/roles\/app-web.env --log-opt max-size="10m" --label service="app" --label role="web" --label destination dhh\/app:latest/, output
assert_match /docker exec kamal-proxy kamal-proxy deploy app-web --target "123:80"/, output assert_match /docker exec kamal-proxy kamal-proxy deploy app-web --target "123"/, output
assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
end end
end end

View File

@@ -21,12 +21,16 @@ class CliBuildTest < CliTestCase
.with(:git, "-C", anything, :status, "--porcelain") .with(:git, "-C", anything, :status, "--porcelain")
.returns("") .returns("")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :buildx, :inspect, "kamal-app-multiarch", "> /dev/null")
.returns("")
run_command("push", "--verbose").tap do |output| run_command("push", "--verbose").tap do |output|
assert_hook_ran "pre-build", output, **hook_variables assert_hook_ran "pre-build", output, **hook_variables
assert_match /Cloning repo into build directory/, output assert_match /Cloning repo into build directory/, output
assert_match /git -C #{Dir.tmpdir}\/kamal-clones\/app-#{pwd_sha} clone #{Dir.pwd}/, 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 --version && docker buildx version/, output
assert_match /docker buildx build --push --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. as .*@localhost/, 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 end
end end
@@ -49,7 +53,7 @@ class CliBuildTest < CliTestCase
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :submodule, :update, "--init") SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :submodule, :update, "--init")
SSHKit::Backend::Abstract.any_instance.expects(:execute) SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".", env: {}) .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) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD) .with(:git, "-C", anything, :"rev-parse", :HEAD)
@@ -74,7 +78,7 @@ class CliBuildTest < CliTestCase
assert_no_match /Cloning repo into build directory/, output assert_no_match /Cloning repo into build directory/, output
assert_hook_ran "pre-build", output, **hook_variables assert_hook_ran "pre-build", output, **hook_variables
assert_match /docker --version && docker buildx version/, output assert_match /docker --version && docker buildx version/, output
assert_match /docker buildx build --push --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile . as .*@localhost/, 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 end
@@ -120,13 +124,10 @@ class CliBuildTest < CliTestCase
.with(:docker, "--version", "&&", :docker, :buildx, "version") .with(:docker, "--version", "&&", :docker, :buildx, "version")
SSHKit::Backend::Abstract.any_instance.expects(:execute) SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :rm, "kamal-local-docker-container") .with(:docker, :buildx, :create, "--use", "--name", "kamal-app-multiarch")
SSHKit::Backend::Abstract.any_instance.expects(:execute) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :buildx, :create, "--name", "kamal-local-docker-container", "--driver=docker-container") .with(:docker, :buildx, :inspect, "kamal-app-multiarch", "> /dev/null")
SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :inspect, "kamal-local-docker-container")
.raises(SSHKit::Command::Failed.new("no builder")) .raises(SSHKit::Command::Failed.new("no builder"))
SSHKit::Backend::Abstract.any_instance.expects(:execute).with { |*args| args.first.start_with?("git") } SSHKit::Backend::Abstract.any_instance.expects(:execute).with { |*args| args.first.start_with?("git") }
@@ -140,7 +141,7 @@ class CliBuildTest < CliTestCase
.returns("") .returns("")
SSHKit::Backend::Abstract.any_instance.expects(:execute) SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".", env: {}) .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", ".")
run_command("push").tap do |output| run_command("push").tap do |output|
assert_match /WARN Missing compatible builder, so creating a new one first/, output assert_match /WARN Missing compatible builder, so creating a new one first/, output
@@ -164,7 +165,7 @@ class CliBuildTest < CliTestCase
error = assert_raises(Kamal::Cli::HookError) { run_command("push") } error = assert_raises(Kamal::Cli::HookError) { run_command("push") }
assert_equal "Hook `pre-build` failed:\nfailed", error.message assert_equal "Hook `pre-build` failed:\nfailed", error.message
assert @executions.none? { |args| args[0..2] == [ :docker, :build ] } assert @executions.none? { |args| args[0..2] == [ :docker, :buildx, :build ] }
end end
test "pull" do test "pull" do
@@ -206,32 +207,23 @@ class CliBuildTest < CliTestCase
test "create" do test "create" do
run_command("create").tap do |output| run_command("create").tap do |output|
assert_match /docker buildx create --name kamal-local-docker-container --driver=docker-container/, output assert_match /docker buildx create --use --name kamal-app-multiarch/, output
end end
end end
test "create remote" do test "create remote" do
run_command("create", fixture: :with_remote_builder).tap do |output| 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 "Running /usr/bin/env true on 1.1.1.5", output
assert_match "docker context create kamal-remote-ssh---app-1-1-1-5-context --description 'kamal-remote-ssh---app-1-1-1-5 host' --docker 'host=ssh://app@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-remote-ssh---app-1-1-1-5 kamal-remote-ssh---app-1-1-1-5-context", output assert_match "docker buildx create --name kamal-app-native-remote kamal-app-native-remote-amd64 --platform linux/amd64", output
end end
end end
test "create remote with custom ports" do test "create remote with custom ports" do
run_command("create", fixture: :with_remote_builder_and_custom_ports).tap do |output| 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 "Running /usr/bin/env true on 1.1.1.5", output
assert_match "docker context create kamal-remote-ssh---app-1-1-1-5-2122-context --description 'kamal-remote-ssh---app-1-1-1-5-2122 host' --docker 'host=ssh://app@1.1.1.5:2122'", 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-remote-ssh---app-1-1-1-5-2122 kamal-remote-ssh---app-1-1-1-5-2122-context", output assert_match "docker buildx create --name kamal-app-native-remote kamal-app-native-remote-amd64 --platform linux/amd64", output
end
end
test "create hybrid" do
run_command("create", fixture: :with_hybrid_builder).tap do |output|
assert_match "Running /usr/bin/env true on 1.1.1.5", output
assert_match "docker buildx create --platform linux/#{Kamal::Utils.docker_arch} --name kamal-hybrid-docker-container-ssh---app-1-1-1-5 --driver=docker-container", output
assert_match "docker context create kamal-hybrid-docker-container-ssh---app-1-1-1-5-context --description 'kamal-hybrid-docker-container-ssh---app-1-1-1-5 host' --docker 'host=ssh://app@1.1.1.5'", output
assert_match "docker buildx create --platform linux/#{Kamal::Utils.docker_arch == "amd64" ? "arm64" : "amd64"} --append --name kamal-hybrid-docker-container-ssh---app-1-1-1-5 kamal-hybrid-docker-container-ssh---app-1-1-1-5-context", output
end end
end end
@@ -248,7 +240,7 @@ class CliBuildTest < CliTestCase
test "remove" do test "remove" do
run_command("remove").tap do |output| run_command("remove").tap do |output|
assert_match /docker buildx rm kamal-local/, output assert_match /docker buildx rm kamal-app-multiarch/, output
end end
end end
@@ -258,7 +250,7 @@ class CliBuildTest < CliTestCase
.returns("docker builder info") .returns("docker builder info")
run_command("details").tap do |output| run_command("details").tap do |output|
assert_match /Builder: local/, output assert_match /Builder: multiarch/, output
assert_match /docker builder info/, output assert_match /docker builder info/, output
end end
end end

View File

@@ -36,11 +36,12 @@ class CliTestCase < ActiveSupport::TestCase
.with { |arg1, arg2| arg1 == :mkdir && arg2 == ".kamal/locks/app" } .with { |arg1, arg2| arg1 == :mkdir && arg2 == ".kamal/locks/app" }
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg1, arg2| arg1 == :rm && arg2 == ".kamal/locks/app/details" } .with { |arg1, arg2| arg1 == :rm && arg2 == ".kamal/locks/app/details" }
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info)
.with(:docker, :buildx, :inspect, "kamal-local-docker-container") .with { |*args| args[0..2] == [ :docker, :buildx, :inspect ] }
.returns("")
end end
def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, subcommand: nil, runtime: false, secrets: false) def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, subcommand: nil, runtime: false)
whoami = `whoami`.chomp whoami = `whoami`.chomp
performer = Kamal::Git.email.presence || whoami performer = Kamal::Git.email.presence || whoami
service = service_version.split("@").first service = service_version.split("@").first
@@ -58,7 +59,6 @@ class CliTestCase < ActiveSupport::TestCase
KAMAL_COMMAND=\"#{command}\"\s KAMAL_COMMAND=\"#{command}\"\s
#{"KAMAL_SUBCOMMAND=\\\"#{subcommand}\\\"\\s" if subcommand} #{"KAMAL_SUBCOMMAND=\\\"#{subcommand}\\\"\\s" if subcommand}
#{"KAMAL_RUNTIME=\\\"\\d+\\\"\\s" if runtime} #{"KAMAL_RUNTIME=\\\"\\d+\\\"\\s" if runtime}
#{"DB_PASSWORD=\"secret\"\\s" if secrets}
;\s/usr/bin/env\s\.kamal/hooks/#{hook} }x ;\s/usr/bin/env\s\.kamal/hooks/#{hook} }x
assert_match expected, output assert_match expected, output

37
test/cli/env_test.rb Normal file
View File

@@ -0,0 +1,37 @@
require_relative "cli_test_case"
class CliEnvTest < CliTestCase
test "push" do
run_command("push").tap do |output|
assert_match "Running /usr/bin/env mkdir -p .kamal/env/roles on 1.1.1.1", output
assert_match "Running /usr/bin/env mkdir -p .kamal/env/traefik on 1.1.1.1", output
assert_match "Running /usr/bin/env mkdir -p .kamal/env/accessories on 1.1.1.1", output
assert_match "Running /usr/bin/env mkdir -p .kamal/env/roles on 1.1.1.1", output
assert_match "Running /usr/bin/env mkdir -p .kamal/env/traefik on 1.1.1.2", output
assert_match "Running /usr/bin/env mkdir -p .kamal/env/accessories on 1.1.1.1", output
assert_match ".kamal/env/roles/app-web.env", output
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
test "delete" do
run_command("delete").tap do |output|
assert_match "Running /usr/bin/env rm -f .kamal/env/roles/app-web.env on 1.1.1.1", output
assert_match "Running /usr/bin/env rm -f .kamal/env/roles/app-web.env on 1.1.1.2", output
assert_match "Running /usr/bin/env rm -f .kamal/env/roles/app-workers.env on 1.1.1.3", output
assert_match "Running /usr/bin/env rm -f .kamal/env/roles/app-workers.env on 1.1.1.4", output
assert_match "Running /usr/bin/env rm -f .kamal/env/traefik/traefik.env on 1.1.1.1", output
assert_match "Running /usr/bin/env rm -f .kamal/env/traefik/traefik.env on 1.1.1.2", output
assert_match "Running /usr/bin/env rm -f .kamal/env/accessories/app-redis.env on 1.1.1.1", output
assert_match "Running /usr/bin/env rm -f .kamal/env/accessories/app-redis.env on 1.1.1.2", output
assert_match "Running /usr/bin/env rm -f .kamal/env/accessories/app-mysql.env on 1.1.1.3", output
end
end
private
def run_command(*command)
stdouted { Kamal::Cli::Env.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) }
end
end

View File

@@ -8,11 +8,14 @@ class CliMainTest < CliTestCase
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:main:envify", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:env:push", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options)
Kamal::Cli::Main.any_instance.expects(:deploy) Kamal::Cli::Main.any_instance.expects(:deploy)
run_command("setup").tap do |output| run_command("setup").tap do |output|
assert_match /Ensure Docker is installed.../, output assert_match /Ensure Docker is installed.../, output
assert_match /Evaluate and push env files.../, output
end end
end end
@@ -20,6 +23,8 @@ class CliMainTest < CliTestCase
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:env:push", [], 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:accessory:boot", [ "all" ], invoke_options)
# deploy # deploy
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: true))
@@ -31,6 +36,7 @@ class CliMainTest < CliTestCase
run_command("setup", "--skip_push").tap do |output| run_command("setup", "--skip_push").tap do |output|
assert_match /Ensure Docker is installed.../, output assert_match /Ensure Docker is installed.../, output
assert_match /Evaluate and push env files.../, output
# deploy # deploy
assert_match /Acquiring the deploy lock/, output assert_match /Acquiring the deploy lock/, output
assert_match /Log into image registry/, output assert_match /Log into image registry/, output
@@ -43,29 +49,27 @@ class CliMainTest < CliTestCase
end end
test "deploy" do test "deploy" do
with_test_secrets("secrets" => "DB_PASSWORD=secret") 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, "verbose" => true }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: false)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: false))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], 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:stale_containers", [], invoke_options.merge(stop: true))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options)
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "deploy" } hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "deploy" }
run_command("deploy", "--verbose").tap do |output| run_command("deploy", "--verbose").tap do |output|
assert_hook_ran "pre-connect", output, **hook_variables assert_hook_ran "pre-connect", output, **hook_variables
assert_match /Log into image registry/, output assert_match /Log into image registry/, output
assert_match /Build and push app image/, output assert_match /Build and push app image/, output
assert_hook_ran "pre-deploy", output, **hook_variables, secrets: true assert_hook_ran "pre-deploy", output, **hook_variables
assert_match /Ensure Traefik is running/, output assert_match /Ensure Traefik is running/, output
assert_match /Detect stale containers/, output assert_match /Detect stale containers/, output
assert_match /Prune old containers and images/, output assert_match /Prune old containers and images/, output
assert_hook_ran "post-deploy", output, **hook_variables, runtime: true, secrets: true assert_hook_ran "post-deploy", output, **hook_variables, runtime: true
end
end end
end end
@@ -117,6 +121,10 @@ class CliMainTest < CliTestCase
.with(:git, "-C", anything, :status, "--porcelain") .with(:git, "-C", anything, :status, "--porcelain")
.returns("") .returns("")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :buildx, :inspect, "kamal-app-multiarch", "> /dev/null")
.returns("")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :info, "--format '{{index .RegistryConfig.Mirrors 0}}'") .with(:docker, :info, "--format '{{index .RegistryConfig.Mirrors 0}}'")
.returns("") .returns("")
@@ -151,6 +159,10 @@ class CliMainTest < CliTestCase
.with(:git, "-C", anything, :status, "--porcelain") .with(:git, "-C", anything, :status, "--porcelain")
.returns("") .returns("")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :buildx, :inspect, "kamal-app-multiarch", "> /dev/null")
.returns("")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :info, "--format '{{index .RegistryConfig.Mirrors 0}}'") .with(:docker, :info, "--format '{{index .RegistryConfig.Mirrors 0}}'")
.returns("") .returns("")
@@ -384,38 +396,40 @@ class CliMainTest < CliTestCase
end end
test "init" do test "init" do
in_dummy_git_repo do Pathname.any_instance.expects(:exist?).returns(false).times(3)
run_command("init").tap do |output| Pathname.any_instance.stubs(:mkpath)
assert_match "Created configuration file in config/deploy.yml", output FileUtils.stubs(:mkdir_p)
assert_match "Created .kamal/secrets file", output FileUtils.stubs(:cp_r)
end FileUtils.stubs(:cp)
assert_file "config/deploy.yml", "service: my-app" run_command("init").tap do |output|
assert_file ".kamal/secrets", "KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD" assert_match /Created configuration file in config\/deploy.yml/, output
assert_match /Created \.env file/, output
end end
end end
test "init with existing config" do test "init with existing config" do
in_dummy_git_repo do Pathname.any_instance.expects(:exist?).returns(true).times(3)
run_command("init")
run_command("init").tap do |output| run_command("init").tap do |output|
assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, output assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, output
assert_no_match /Added .kamal\/secrets/, output
end
end end
end end
test "init with bundle option" do test "init with bundle option" do
in_dummy_git_repo do Pathname.any_instance.expects(:exist?).returns(false).times(4)
run_command("init", "--bundle").tap do |output| Pathname.any_instance.stubs(:mkpath)
assert_match "Created configuration file in config/deploy.yml", output FileUtils.stubs(:mkdir_p)
assert_match "Created .kamal/secrets file", output FileUtils.stubs(:cp_r)
assert_match /Adding Kamal to Gemfile and bundle/, output FileUtils.stubs(:cp)
assert_match /bundle add kamal/, output
assert_match /bundle binstubs kamal/, output run_command("init", "--bundle").tap do |output|
assert_match /Created binstub file in bin\/kamal/, output assert_match /Created configuration file in config\/deploy.yml/, output
end assert_match /Created \.env file/, output
assert_match /Adding Kamal to Gemfile and bundle/, output
assert_match /bundle add kamal/, output
assert_match /bundle binstubs kamal/, output
assert_match /Created binstub file in bin\/kamal/, output
end end
end end
@@ -432,6 +446,50 @@ class CliMainTest < CliTestCase
end end
end end
test "envify" do
with_test_dotenv(".env.erb": "HELLO=<%= 'world' %>") do
run_command("envify")
assert_equal("HELLO=world", File.read(".env"))
end
end
test "envify with blank line trimming" do
file = <<~EOF
HELLO=<%= 'world' %>
<% if true -%>
KEY=value
<% end -%>
EOF
with_test_dotenv(".env.erb": file) do
run_command("envify")
assert_equal("HELLO=world\nKEY=value\n", File.read(".env"))
end
end
test "envify with destination" do
with_test_dotenv(".env.world.erb": "HELLO=<%= 'world' %>") do
run_command("envify", "-d", "world", config_file: "deploy_for_dest")
assert_equal "HELLO=world", File.read(".env.world")
end
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 "envify with clean env" do
with_test_dotenv(".env": "HELLO=already", ".env.erb": "HELLO=<%= ENV.fetch 'HELLO', 'never' %>") do
run_command("envify", "--skip-push")
assert_equal "HELLO=never", File.read(".env")
end
end
test "remove with confirmation" do test "remove with confirmation" do
run_command("remove", "-y", config_file: "deploy_with_accessories").tap do |output| run_command("remove", "-y", config_file: "deploy_with_accessories").tap do |output|
assert_match /docker container stop traefik/, output assert_match /docker container stop traefik/, output
@@ -522,16 +580,18 @@ class CliMainTest < CliTestCase
end end
end end
def in_dummy_git_repo def with_test_dotenv(**files)
Dir.mktmpdir do |tmpdir| Dir.mktmpdir do |dir|
Dir.chdir(tmpdir) do fixtures_dup = File.join(dir, "test")
`git init` FileUtils.mkdir_p(fixtures_dup)
FileUtils.cp_r("test/fixtures/", fixtures_dup)
Dir.chdir(dir) do
files.each do |filename, contents|
File.binwrite(filename.to_s, contents)
end
yield yield
end end
end end
end end
def assert_file(file, content)
assert_match content, File.read(file)
end
end end

View File

@@ -4,14 +4,14 @@ class CliProxyTest < CliTestCase
test "boot" do test "boot" do
run_command("boot").tap do |output| run_command("boot").tap do |output|
assert_match "docker login", output assert_match "docker login", output
assert_match "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/proxy/config:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}", output assert_match "docker run --name kamal-proxy --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume kamal-proxy:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}", output
end end
end end
test "reboot" do test "reboot" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet") .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{.NetworkSettings.IPAddress}}{{range $k, $v := .NetworkSettings.Ports}}{{printf \":%s\" $k}}{{break}}{{end}}'", "|", :sed, "-e", "'s/\\/tcp$//'")
.returns("abcdefabcdef") .returns("172.1.0.2:80")
.at_least_once .at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
@@ -24,8 +24,8 @@ class CliProxyTest < CliTestCase
assert_match "docker container stop traefik on 1.1.1.1", output assert_match "docker container stop traefik on 1.1.1.1", output
assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.1", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.1", output
assert_match "docker container prune --force --filter label=org.opencontainers.image.title=Traefik on 1.1.1.1", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=Traefik on 1.1.1.1", output
assert_match "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/proxy/config:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE} on 1.1.1.1", output assert_match "docker run --name kamal-proxy --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume kamal-proxy:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE} on 1.1.1.1", output
assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target \"abcdefabcdef:80\" --deploy-timeout \"6s\" --buffer-requests --buffer-responses --log-request-header \"Cache-Control\" --log-request-header \"Last-Modified\" on 1.1.1.1", output assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target \"172.1.0.2:80\" --deploy-timeout \"6s\" on 1.1.1.1", output
assert_match "docker container stop kamal-proxy on 1.1.1.2", output assert_match "docker container stop kamal-proxy on 1.1.1.2", output
assert_match "docker container stop traefik on 1.1.1.2", output assert_match "docker container stop traefik on 1.1.1.2", output
@@ -37,8 +37,8 @@ class CliProxyTest < CliTestCase
test "reboot --rolling" do test "reboot --rolling" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet") .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{.NetworkSettings.IPAddress}}{{range $k, $v := .NetworkSettings.Ports}}{{printf \":%s\" $k}}{{break}}{{end}}'", "|", :sed, "-e", "'s/\\/tcp$//'")
.returns("abcdefabcdef") .returns("172.1.0.2:80")
.at_least_once .at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)

View File

@@ -1,22 +0,0 @@
require_relative "cli_test_case"
class CliSecretsTest < CliTestCase
test "fetch" do
assert_equal \
"\\{\\\"foo\\\":\\\"oof\\\",\\\"bar\\\":\\\"rab\\\",\\\"baz\\\":\\\"zab\\\"\\}",
run_command("fetch", "foo", "bar", "baz", "--account", "myaccount", "--adapter", "test")
end
test "extract" do
assert_equal "oof", run_command("extract", "foo", "{\"foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}")
end
test "extract match from end" do
assert_equal "oof", run_command("extract", "foo", "{\"abc/foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}")
end
private
def run_command(*command)
stdouted { Kamal::Cli::Secrets.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) }
end
end

View File

@@ -2,12 +2,9 @@ require "test_helper"
class CommandsAccessoryTest < ActiveSupport::TestCase class CommandsAccessoryTest < ActiveSupport::TestCase
setup do setup do
setup_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123")
@config = { @config = {
service: "app", image: "dhh/app", registry: { "server" => "private.registry", "username" => "dhh", "password" => "secret" }, service: "app", image: "dhh/app", registry: { "server" => "private.registry", "username" => "dhh", "password" => "secret" },
servers: [ "1.1.1.1" ], servers: [ "1.1.1.1" ],
builder: { "arch" => "amd64" },
accessories: { accessories: {
"mysql" => { "mysql" => {
"image" => "private.registry/mysql:8.0", "image" => "private.registry/mysql:8.0",
@@ -43,23 +40,25 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
} }
} }
} }
ENV["MYSQL_ROOT_PASSWORD"] = "secret123"
end end
teardown do teardown do
teardown_test_secrets ENV.delete("MYSQL_ROOT_PASSWORD")
end end
test "run" do test "run" do
assert_equal \ assert_equal \
"docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/env/accessories/app-mysql.env --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 --env MYSQL_ROOT_HOST=\"%\" --label service=\"app-mysql\" private.registry/mysql:8.0",
new_command(:mysql).run.join(" ") new_command(:mysql).run.join(" ")
assert_equal \ assert_equal \
"docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env SOMETHING=\"else\" --env-file .kamal/env/accessories/app-redis.env --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 --env SOMETHING=\"else\" --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest",
new_command(:redis).run.join(" ") new_command(:redis).run.join(" ")
assert_equal \ assert_equal \
"docker run --name custom-busybox --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env-file .kamal/env/accessories/custom-busybox.env --label service=\"custom-busybox\" busybox:latest", "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",
new_command(:busybox).run.join(" ") new_command(:busybox).run.join(" ")
end end
@@ -67,7 +66,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } }
assert_equal \ assert_equal \
"docker run --name custom-busybox --detach --restart unless-stopped --network kamal --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 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",
new_command(:busybox).run.join(" ") new_command(:busybox).run.join(" ")
end end
@@ -92,7 +91,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "execute in new container" do test "execute in new container" do
assert_equal \ assert_equal \
"docker run --rm --network kamal --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/env/accessories/app-mysql.env private.registry/mysql:8.0 mysql -u root", "docker run --rm --env-file .kamal/env/accessories/app-mysql.env --env MYSQL_ROOT_HOST=\"%\" private.registry/mysql:8.0 mysql -u root",
new_command(:mysql).execute_in_new_container("mysql", "-u", "root").join(" ") new_command(:mysql).execute_in_new_container("mysql", "-u", "root").join(" ")
end end
@@ -104,7 +103,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "execute in new container over ssh" do test "execute in new container over ssh" do
new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do
assert_match %r{docker run -it --rm --network kamal --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/env/accessories/app-mysql.env private.registry/mysql:8.0 mysql -u root}, 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},
new_command(:mysql).execute_in_new_container_over_ssh("mysql", "-u", "root") new_command(:mysql).execute_in_new_container_over_ssh("mysql", "-u", "root")
end end
end end
@@ -150,6 +149,14 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
new_command(:mysql).remove_image.join(" ") new_command(:mysql).remove_image.join(" ")
end end
test "make_env_directory" do
assert_equal "mkdir -p .kamal/env/accessories", new_command(:mysql).make_env_directory.join(" ")
end
test "remove_env_file" do
assert_equal "rm -f .kamal/env/accessories/app-mysql.env", new_command(:mysql).remove_env_file.join(" ")
end
private private
def new_command(accessory) def new_command(accessory)
Kamal::Commands::Accessory.new(Kamal::Configuration.new(@config), name: accessory) Kamal::Commands::Accessory.new(Kamal::Configuration.new(@config), name: accessory)

View File

@@ -2,14 +2,14 @@ require "test_helper"
class CommandsAppTest < ActiveSupport::TestCase class CommandsAppTest < ActiveSupport::TestCase
setup do setup do
setup_test_secrets("secrets" => "RAILS_MASTER_KEY=456") ENV["RAILS_MASTER_KEY"] = "456"
Kamal::Configuration.any_instance.stubs(:run_id).returns("12345678901234567890123456789012") 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" ] }, builder: { "arch" => "amd64" } } @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], env: { "secret" => [ "RAILS_MASTER_KEY" ] } }
end end
teardown do teardown do
teardown_test_secrets ENV.delete("RAILS_MASTER_KEY")
end end
test "run" do test "run" do
@@ -85,7 +85,7 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } }
assert_equal \ assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env ENV1=\"value1\" --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\" -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",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -219,7 +219,7 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } }
assert_equal \ assert_equal \
"docker run --rm --env ENV1=\"value1\" --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails db:setup", "docker run --rm --env-file .kamal/env/roles/app-web.env --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", env: {}).join(" ")
end end
@@ -251,7 +251,7 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:servers] = [ { "1.1.1.1" => "tag1" } ] @config[:servers] = [ { "1.1.1.1" => "tag1" } ]
@config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } }
assert_equal "ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --env ENV1=\"value1\" --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails c'", 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: {}) new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {})
end end
@@ -412,6 +412,14 @@ class CommandsAppTest < ActiveSupport::TestCase
new_command.tag_latest_image.join(" ") new_command.tag_latest_image.join(" ")
end end
test "make_env_directory" do
assert_equal "mkdir -p .kamal/env/roles", new_command.make_env_directory.join(" ")
end
test "remove_env_file" do
assert_equal "rm -f .kamal/env/roles/app-web.env", new_command.remove_env_file.join(" ")
end
test "cord" do 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(" ") 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 end

View File

@@ -8,7 +8,7 @@ class CommandsAuditorTest < ActiveSupport::TestCase
freeze_time freeze_time
@config = { @config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" }, servers: [ "1.1.1.1" ] service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ]
} }
@auditor = new_command @auditor = new_command
@@ -18,7 +18,6 @@ class CommandsAuditorTest < ActiveSupport::TestCase
test "record" do test "record" do
assert_equal [ assert_equal [
:mkdir, "-p", ".kamal", "&&",
:echo, :echo,
"[#{@recorded_at}] [#{@performer}]", "[#{@recorded_at}] [#{@performer}]",
"app removed container", "app removed container",
@@ -29,7 +28,6 @@ class CommandsAuditorTest < ActiveSupport::TestCase
test "record with destination" do test "record with destination" do
new_command(destination: "staging").tap do |auditor| new_command(destination: "staging").tap do |auditor|
assert_equal [ assert_equal [
:mkdir, "-p", ".kamal", "&&",
:echo, :echo,
"[#{@recorded_at}] [#{@performer}] [staging]", "[#{@recorded_at}] [#{@performer}] [staging]",
"app removed container", "app removed container",
@@ -41,7 +39,6 @@ class CommandsAuditorTest < ActiveSupport::TestCase
test "record with command details" do test "record with command details" do
new_command(role: "web").tap do |auditor| new_command(role: "web").tap do |auditor|
assert_equal [ assert_equal [
:mkdir, "-p", ".kamal", "&&",
:echo, :echo,
"[#{@recorded_at}] [#{@performer}] [web]", "[#{@recorded_at}] [#{@performer}] [web]",
"app removed container", "app removed container",
@@ -52,7 +49,6 @@ class CommandsAuditorTest < ActiveSupport::TestCase
test "record with arg details" do test "record with arg details" do
assert_equal [ assert_equal [
:mkdir, "-p", ".kamal", "&&",
:echo, :echo,
"[#{@recorded_at}] [#{@performer}] [value]", "[#{@recorded_at}] [#{@performer}] [value]",
"app removed container", "app removed container",

View File

@@ -2,62 +2,54 @@ require "test_helper"
class CommandsBuilderTest < ActiveSupport::TestCase class CommandsBuilderTest < ActiveSupport::TestCase
setup do setup do
@config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: { "arch" => "amd64" } } @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ] }
end end
test "target linux/amd64 locally by default" do test "target multiarch by default" do
builder = new_builder_command(builder: { "cache" => { "type" => "gha" } }) builder = new_builder_command(builder: { "cache" => { "type" => "gha" } })
assert_equal "local", builder.name assert_equal "multiarch", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", "docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-app-multiarch -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
test "target specified arch locally by default" do test "target native when multiarch is off" do
builder = new_builder_command(builder: { "arch" => [ "amd64" ] }) builder = new_builder_command(builder: { "multiarch" => false })
assert_equal "local", builder.name assert_equal "native", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .", "docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile . && docker push dhh/app:123 && docker push dhh/app:latest",
builder.push.join(" ") builder.push.join(" ")
end end
test "build with caching" do test "target native cached when multiarch is off and cache is set" do
builder = new_builder_command(builder: { "cache" => { "type" => "gha" } }) builder = new_builder_command(builder: { "multiarch" => false, "cache" => { "type" => "gha" } })
assert_equal "local", builder.name assert_equal "native/cached", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", "docker buildx build --push -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
test "hybrid build if remote is set and building multiarch" do test "target multiarch remote when local and remote is set" do
builder = new_builder_command(builder: { "arch" => [ "amd64", "arm64" ], "remote" => "ssh://app@127.0.0.1", "cache" => { "type" => "gha" } }) builder = new_builder_command(builder: { "local" => {}, "remote" => {}, "cache" => { "type" => "gha" } })
assert_equal "hybrid", builder.name assert_equal "multiarch/remote", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-hybrid-docker-container-ssh---app-127-0-0-1 -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", "docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-app-multiarch-remote -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
test "remote build if remote is set and local disabled" do test "target multiarch local when arch is set" do
builder = new_builder_command(builder: { "arch" => [ "amd64", "arm64" ], "remote" => "ssh://app@127.0.0.1", "cache" => { "type" => "gha" }, "local" => false }) builder = new_builder_command(builder: { "local" => { "arch" => "amd64" } })
assert_equal "remote", builder.name assert_equal "multiarch", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-remote-ssh---app-127-0-0-1 -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", "docker buildx build --push --platform linux/amd64 --builder kamal-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
test "target remote when remote set and arch is non local" do test "target native remote when only remote is set" do
builder = new_builder_command(builder: { "arch" => [ "#{remote_arch}" ], "remote" => "ssh://app@host", "cache" => { "type" => "gha" } }) builder = new_builder_command(builder: { "remote" => { "arch" => "amd64" }, "cache" => { "type" => "gha" } })
assert_equal "remote", builder.name assert_equal "native/remote", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/#{remote_arch} --builder kamal-remote-ssh---app-host -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", "docker buildx build --push --platform linux/amd64 --builder kamal-app-native-remote -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ")
end
test "target local when remote set and arch is local" do
builder = new_builder_command(builder: { "arch" => [ "#{local_arch}" ], "remote" => "ssh://app@host", "cache" => { "type" => "gha" } })
assert_equal "local", builder.name
assert_equal \
"docker buildx build --push --platform linux/#{local_arch} --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -69,13 +61,10 @@ class CommandsBuilderTest < ActiveSupport::TestCase
end end
test "build secrets" do test "build secrets" do
with_test_secrets("secrets" => "token_a=foo\ntoken_b=bar") do builder = new_builder_command(builder: { "secrets" => [ "token_a", "token_b" ] })
FileUtils.touch("Dockerfile") assert_equal \
builder = new_builder_command(builder: { "secrets" => [ "token_a", "token_b" ] }) "-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"token_a\" --secret id=\"token_b\" --file Dockerfile",
assert_equal \ builder.target.build_options.join(" ")
"-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(" ")
end
end end
test "build dockerfile" do test "build dockerfile" do
@@ -104,25 +93,29 @@ class CommandsBuilderTest < ActiveSupport::TestCase
test "build context" do test "build context" do
builder = new_builder_command(builder: { "context" => ".." }) builder = new_builder_command(builder: { "context" => ".." })
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile ..", "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 ..",
builder.push.join(" ") builder.push.join(" ")
end end
test "push with build args" do test "native push with build args" do
builder = new_builder_command(builder: { "multiarch" => false, "args" => { "a" => 1, "b" => 2 } })
assert_equal \
"docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile . && docker push dhh/app:123 && docker push dhh/app:latest",
builder.push.join(" ")
end
test "multiarch push with build args" do
builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } }) builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } })
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile .", "docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
test "push with build secrets" do test "native push with build secrets" do
with_test_secrets("secrets" => "a=foo\nb=bar") do builder = new_builder_command(builder: { "multiarch" => false, "secrets" => [ "a", "b" ] })
FileUtils.touch("Dockerfile") assert_equal \
builder = new_builder_command(builder: { "secrets" => [ "a", "b" ] }) "docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" --file Dockerfile . && docker push dhh/app:123 && docker push dhh/app:latest",
assert_equal \ builder.push.join(" ")
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" --file Dockerfile .",
builder.push.join(" ")
end
end end
test "build with ssh agent socket" do test "build with ssh agent socket" do
@@ -137,13 +130,76 @@ class CommandsBuilderTest < ActiveSupport::TestCase
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(" ") 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 end
test "context build" do test "multiarch context build" do
builder = new_builder_command(builder: { "context" => "./foo" }) builder = new_builder_command(builder: { "context" => "./foo" })
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile ./foo", "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(" ") builder.push.join(" ")
end 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
test "multiarch context hosts" do
command = new_builder_command
assert_equal "docker buildx inspect kamal-app-multiarch > /dev/null", command.context_hosts.join(" ")
assert_equal "", command.config_context_hosts.join(" ")
end
test "native context hosts" do
command = new_builder_command(builder: { "multiarch" => false })
assert_equal :true, command.context_hosts
assert_equal "", command.config_context_hosts.join(" ")
end
test "native cached context hosts" do
command = new_builder_command(builder: { "multiarch" => false, "cache" => { "type" => "registry" } })
assert_equal "docker buildx inspect kamal-app-native-cached > /dev/null", command.context_hosts.join(" ")
assert_equal "", command.config_context_hosts.join(" ")
end
test "native remote context hosts" do
command = new_builder_command(builder: { "remote" => { "arch" => "amd64", "host" => "ssh://host" } })
assert_equal "docker context inspect kamal-app-native-remote-amd64 --format '{{.Endpoints.docker.Host}}'", command.context_hosts.join(" ")
assert_equal [ "ssh://host" ], command.config_context_hosts
end
test "multiarch remote context hosts" do
command = new_builder_command(builder: {
"remote" => { "arch" => "amd64", "host" => "ssh://host" },
"local" => { "arch" => "arm64" }
})
assert_equal "docker context inspect kamal-app-multiarch-remote-arm64 --format '{{.Endpoints.docker.Host}}' ; docker context inspect kamal-app-multiarch-remote-amd64 --format '{{.Endpoints.docker.Host}}'", command.context_hosts.join(" ")
assert_equal [ "ssh://host" ], command.config_context_hosts
end
test "multiarch remote context hosts with local host" do
command = new_builder_command(builder: {
"remote" => { "arch" => "amd64", "host" => "ssh://host" },
"local" => { "arch" => "arm64", "host" => "unix:///var/run/docker.sock" }
})
assert_equal "docker context inspect kamal-app-multiarch-remote-arm64 --format '{{.Endpoints.docker.Host}}' ; docker context inspect kamal-app-multiarch-remote-amd64 --format '{{.Endpoints.docker.Host}}'", command.context_hosts.join(" ")
assert_equal [ "unix:///var/run/docker.sock", "ssh://host" ], command.config_context_hosts
end
test "mirror count" do test "mirror count" do
command = new_builder_command command = new_builder_command
assert_equal "docker info --format '{{index .RegistryConfig.Mirrors 0}}'", command.first_mirror.join(" ") assert_equal "docker info --format '{{index .RegistryConfig.Mirrors 0}}'", command.first_mirror.join(" ")
@@ -151,18 +207,10 @@ class CommandsBuilderTest < ActiveSupport::TestCase
private private
def new_builder_command(additional_config = {}) def new_builder_command(additional_config = {})
Kamal::Commands::Builder.new(Kamal::Configuration.new(@config.deep_merge(additional_config), version: "123")) Kamal::Commands::Builder.new(Kamal::Configuration.new(@config.merge(additional_config), version: "123"))
end end
def build_directory def build_directory
"#{Dir.tmpdir}/kamal-clones/app/kamal/" "#{Dir.tmpdir}/kamal-clones/app/kamal/"
end end
def local_arch
Kamal::Utils.docker_arch
end
def remote_arch
Kamal::Utils.docker_arch == "arm64" ? "amd64" : "arm64"
end
end end

View File

@@ -3,7 +3,7 @@ require "test_helper"
class CommandsDockerTest < ActiveSupport::TestCase class CommandsDockerTest < ActiveSupport::TestCase
setup do setup do
@config = { @config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: { "arch" => "amd64" } service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ]
} }
@docker = Kamal::Commands::Docker.new(Kamal::Configuration.new(@config)) @docker = Kamal::Commands::Docker.new(Kamal::Configuration.new(@config))
end end

View File

@@ -8,7 +8,7 @@ class CommandsHookTest < ActiveSupport::TestCase
@config = { @config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
builder: { "arch" => "amd64" }, traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } } traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
} }
@performer = Kamal::Git.email.presence || `whoami`.chomp @performer = Kamal::Git.email.presence || `whoami`.chomp
@@ -39,21 +39,6 @@ class CommandsHookTest < ActiveSupport::TestCase
], new_command(hooks_path: "custom/hooks/path").run("foo") ], new_command(hooks_path: "custom/hooks/path").run("foo")
end end
test "hook with secrets" do
with_test_secrets("secrets" => "DB_PASSWORD=secret") do
assert_equal [
".kamal/hooks/foo",
{ env: {
"KAMAL_RECORDED_AT" => @recorded_at,
"KAMAL_PERFORMER" => @performer,
"KAMAL_VERSION" => "123",
"KAMAL_SERVICE_VERSION" => "app@123",
"KAMAL_SERVICE" => "app",
"DB_PASSWORD" => "secret" } }
], new_command(env: { "secret" => [ "DB_PASSWORD" ] }).run("foo", secrets: true)
end
end
private private
def new_command(**extra_config) def new_command(**extra_config)
Kamal::Commands::Hook.new(Kamal::Configuration.new(@config.merge(**extra_config), version: "123")) Kamal::Commands::Hook.new(Kamal::Configuration.new(@config.merge(**extra_config), version: "123"))

View File

@@ -4,7 +4,7 @@ class CommandsLockTest < ActiveSupport::TestCase
setup do setup do
@config = { @config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
builder: { "arch" => "amd64" }, traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } } traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
} }
end end

View File

@@ -3,7 +3,7 @@ require "test_helper"
class CommandsProxyTest < ActiveSupport::TestCase class CommandsProxyTest < ActiveSupport::TestCase
setup do setup do
@config = { @config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: { "arch" => "amd64" } service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ]
} }
ENV["EXAMPLE_API_KEY"] = "456" ENV["EXAMPLE_API_KEY"] = "456"
@@ -15,13 +15,13 @@ class CommandsProxyTest < ActiveSupport::TestCase
test "run" do test "run" do
assert_equal \ assert_equal \
"docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/proxy/config:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}", "docker run --name kamal-proxy --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume kamal-proxy:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}",
new_command.run.join(" ") new_command.run.join(" ")
end end
test "run with ports configured" do test "run with ports configured" do
assert_equal \ assert_equal \
"docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/proxy/config:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}", "docker run --name kamal-proxy --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume kamal-proxy:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -29,7 +29,7 @@ class CommandsProxyTest < ActiveSupport::TestCase
@config.delete(:proxy) @config.delete(:proxy)
assert_equal \ assert_equal \
"docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/proxy/config:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}", "docker run --name kamal-proxy --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume kamal-proxy:/root/.config/kamal-proxy --log-opt max-size=\"10m\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -37,7 +37,7 @@ class CommandsProxyTest < ActiveSupport::TestCase
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } }
assert_equal \ assert_equal \
"docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume $(pwd)/.kamal/proxy/config:/root/.config/kamal-proxy --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}", "docker run --name kamal-proxy --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume /var/run/docker.sock:/var/run/docker.sock --volume kamal-proxy:/root/.config/kamal-proxy --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" #{Kamal::Configuration::Proxy::DEFAULT_IMAGE}",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -109,14 +109,14 @@ class CommandsProxyTest < ActiveSupport::TestCase
test "deploy" do test "deploy" do
assert_equal \ assert_equal \
"docker exec kamal-proxy kamal-proxy deploy service --target \"172.1.0.2:80\" --buffer-requests --buffer-responses --log-request-header \"Cache-Control\" --log-request-header \"Last-Modified\"", "docker exec kamal-proxy kamal-proxy deploy service --target \"172.1.0.2:80\"",
new_command.deploy("service", target: "172.1.0.2").join(" ") new_command.deploy("service", target: "172.1.0.2:80").join(" ")
end end
test "remove" do test "remove" do
assert_equal \ assert_equal \
"docker exec kamal-proxy kamal-proxy remove service --target \"172.1.0.2:80\"", "docker exec kamal-proxy kamal-proxy remove service --target \"172.1.0.2:80\"",
new_command.remove("service", target: "172.1.0.2").join(" ") new_command.remove("service", target: "172.1.0.2:80").join(" ")
end end
private private

View File

@@ -4,7 +4,7 @@ class CommandsPruneTest < ActiveSupport::TestCase
setup do setup do
@config = { @config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
builder: { "arch" => "amd64" }, traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } } traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
} }
end end

View File

@@ -8,55 +8,53 @@ class CommandsRegistryTest < ActiveSupport::TestCase
"password" => "secret", "password" => "secret",
"server" => "hub.docker.com" "server" => "hub.docker.com"
}, },
builder: { "arch" => "amd64" },
servers: [ "1.1.1.1" ] servers: [ "1.1.1.1" ]
} }
@registry = Kamal::Commands::Registry.new Kamal::Configuration.new(@config)
end end
test "registry login" do test "registry login" do
assert_equal \ assert_equal \
"docker login hub.docker.com -u \"dhh\" -p \"secret\"", "docker login hub.docker.com -u \"dhh\" -p \"secret\"",
registry.login.join(" ") @registry.login.join(" ")
end end
test "registry login with ENV password" do test "registry login with ENV password" do
with_test_secrets("secrets" => "KAMAL_REGISTRY_PASSWORD=more-secret") do ENV["KAMAL_REGISTRY_PASSWORD"] = "more-secret"
@config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ] @config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ]
assert_equal \ 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(" ") @registry.login.join(" ")
end ensure
ENV.delete("KAMAL_REGISTRY_PASSWORD")
end end
test "registry login escape password" do test "registry login escape password" do
with_test_secrets("secrets" => "KAMAL_REGISTRY_PASSWORD=more-secret'\"") do ENV["KAMAL_REGISTRY_PASSWORD"] = "more-secret'\""
@config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ] @config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ]
assert_equal \ 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(" ") @registry.login.join(" ")
end ensure
ENV.delete("KAMAL_REGISTRY_PASSWORD")
end end
test "registry login with ENV username" do test "registry login with ENV username" do
with_test_secrets("secrets" => "KAMAL_REGISTRY_USERNAME=also-secret") do ENV["KAMAL_REGISTRY_USERNAME"] = "also-secret"
@config[:registry]["username"] = [ "KAMAL_REGISTRY_USERNAME" ] @config[:registry]["username"] = [ "KAMAL_REGISTRY_USERNAME" ]
assert_equal \ 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(" ") @registry.login.join(" ")
end ensure
ENV.delete("KAMAL_REGISTRY_USERNAME")
end end
test "registry logout" do test "registry logout" do
assert_equal \ assert_equal \
"docker logout hub.docker.com", "docker logout hub.docker.com",
registry.logout.join(" ") @registry.logout.join(" ")
end end
private
def registry
Kamal::Commands::Registry.new Kamal::Configuration.new(@config)
end
end end

View File

@@ -4,7 +4,7 @@ class CommandsServerTest < ActiveSupport::TestCase
setup do setup do
@config = { @config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
builder: { "arch" => "amd64" }, traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } } traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
} }
end end
@@ -12,6 +12,10 @@ class CommandsServerTest < ActiveSupport::TestCase
assert_equal "mkdir -p .kamal", new_command.ensure_run_directory.join(" ") assert_equal "mkdir -p .kamal", new_command.ensure_run_directory.join(" ")
end end
test "ensure non default run directory" do
assert_equal "mkdir -p /var/run/kamal", new_command(run_directory: "/var/run/kamal").ensure_run_directory.join(" ")
end
private private
def new_command(extra_config = {}) def new_command(extra_config = {})
Kamal::Commands::Server.new(Kamal::Configuration.new(@config.merge(extra_config))) Kamal::Commands::Server.new(Kamal::Configuration.new(@config.merge(extra_config)))

View File

@@ -5,15 +5,15 @@ class CommandsTraefikTest < ActiveSupport::TestCase
@image = "traefik:test" @image = "traefik:test"
@config = { @config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: { "arch" => "amd64" }, 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" } } traefik: { "image" => @image, "args" => { "accesslog.format" => "json", "api.insecure" => true, "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
} }
setup_test_secrets("secrets" => "EXAMPLE_API_KEY=456") ENV["EXAMPLE_API_KEY"] = "456"
end end
teardown do teardown do
teardown_test_secrets ENV.delete("EXAMPLE_API_KEY")
end end
test "run" do test "run" do
@@ -81,9 +81,9 @@ class CommandsTraefikTest < ActiveSupport::TestCase
"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\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", "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\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ") new_command.run.join(" ")
@config[:traefik]["env"] = { "EXAMPLE_API_KEY" => "456" } @config[:traefik]["env"] = { "secret" => %w[EXAMPLE_API_KEY] }
assert_equal \ assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env EXAMPLE_API_KEY=\"456\" --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\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", "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\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -188,6 +188,20 @@ class CommandsTraefikTest < ActiveSupport::TestCase
new_command.follow_logs(host: @config[:servers].first, grep: "hello!") new_command.follow_logs(host: @config[:servers].first, grep: "hello!")
end end
test "secrets io" do
@config[:traefik]["env"] = { "secret" => %w[EXAMPLE_API_KEY] }
assert_equal "EXAMPLE_API_KEY=456\n", new_command.env.secrets_io.string
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 private
def new_command def new_command
Kamal::Commands::Traefik.new(Kamal::Configuration.new(@config, version: "123")) Kamal::Commands::Traefik.new(Kamal::Configuration.new(@config, version: "123"))

View File

@@ -8,7 +8,6 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
"web" => [ "1.1.1.1", "1.1.1.2" ], "web" => [ "1.1.1.1", "1.1.1.2" ],
"workers" => [ "1.1.1.3", "1.1.1.4" ] "workers" => [ "1.1.1.3", "1.1.1.4" ]
}, },
builder: { "arch" => "amd64" },
env: { "REDIS_URL" => "redis://x/y" }, env: { "REDIS_URL" => "redis://x/y" },
accessories: { accessories: {
"mysql" => { "mysql" => {
@@ -116,14 +115,25 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
end end
test "env args" do test "env args" do
with_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123") do assert_equal [ "--env-file", ".kamal/env/accessories/app-mysql.env", "--env", "MYSQL_ROOT_HOST=\"%\"" ], @config.accessory(:mysql).env_args
config = Kamal::Configuration.new(@deploy) assert_equal [ "--env-file", ".kamal/env/accessories/app-redis.env", "--env", "SOMETHING=\"else\"" ], @config.accessory(:redis).env_args
end
assert_equal [ "--env", "MYSQL_ROOT_HOST=\"%\"", "--env-file", ".kamal/env/accessories/app-mysql.env" ], config.accessory(:mysql).env_args.map(&:to_s) test "env with secrets" do
assert_equal "MYSQL_ROOT_PASSWORD=secret123\n", config.accessory(:mysql).secrets_io.string ENV["MYSQL_ROOT_PASSWORD"] = "secret123"
assert_equal [ "--env", "SOMETHING=\"else\"", "--env-file", ".kamal/env/accessories/app-redis.env" ], @config.accessory(:redis).env_args
assert_equal "\n", config.accessory(:redis).secrets_io.string expected_secrets_file = <<~ENV
end MYSQL_ROOT_PASSWORD=secret123
ENV
assert_equal expected_secrets_file, @config.accessory(:mysql).env.secrets_io.string
assert_equal [ "--env-file", ".kamal/env/accessories/app-mysql.env", "--env", "MYSQL_ROOT_HOST=\"%\"" ], @config.accessory(:mysql).env_args
ensure
ENV["MYSQL_ROOT_PASSWORD"] = nil
end
test "env secrets path" do
assert_equal ".kamal/env/accessories/app-mysql.env", @config.accessory(:mysql).env.secrets_file
end end
test "volume args" do test "volume args" do

View File

@@ -4,33 +4,56 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase
setup do setup do
@deploy = { @deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
builder: { "arch" => "amd64" }, servers: [ "1.1.1.1" ] servers: [ "1.1.1.1" ]
}
@deploy_with_builder_option = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ "1.1.1.1" ],
builder: {}
} }
end end
test "multiarch?" do
assert_equal true, config.builder.multiarch?
end
test "setting multiarch to false" do
@deploy_with_builder_option[:builder] = { "multiarch" => false }
assert_equal false, config_with_builder_option.builder.multiarch?
end
test "local?" do test "local?" do
assert_equal true, config.builder.local? assert_equal false, config.builder.local?
end end
test "remote?" do test "remote?" do
assert_equal false, config.builder.remote? assert_equal false, config.builder.remote?
end end
test "remote" do test "remote_arch" do
assert_nil config.builder.remote assert_nil config.builder.remote_arch
end
test "remote_host" do
assert_nil config.builder.remote_host
end end
test "setting both local and remote configs" do test "setting both local and remote configs" do
@deploy[:builder] = { @deploy_with_builder_option[:builder] = {
"arch" => [ "amd64", "arm64" ], "local" => { "arch" => "arm64", "host" => "unix:///Users/<%= `whoami`.strip %>/.docker/run/docker.sock" },
"remote" => "ssh://root@192.168.0.1" "remote" => { "arch" => "amd64", "host" => "ssh://root@192.168.0.1" }
} }
assert_equal true, config.builder.local? assert_equal true, config_with_builder_option.builder.local?
assert_equal true, config.builder.remote? assert_equal true, config_with_builder_option.builder.remote?
assert_equal [ "amd64", "arm64" ], config.builder.arches assert_equal "amd64", config_with_builder_option.builder.remote_arch
assert_equal "ssh://root@192.168.0.1", config.builder.remote assert_equal "ssh://root@192.168.0.1", config_with_builder_option.builder.remote_host
assert_equal "arm64", config_with_builder_option.builder.local_arch
assert_equal "unix:///Users/<%= `whoami`.strip %>/.docker/run/docker.sock", config_with_builder_option.builder.local_host
end end
test "cached?" do test "cached?" do
@@ -38,10 +61,10 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase
end end
test "invalid cache type specified" do test "invalid cache type specified" do
@deploy[:builder]["cache"] = { "type" => "invalid" } @deploy_with_builder_option[:builder] = { "cache" => { "type" => "invalid" } }
assert_raises(Kamal::ConfigurationError) do assert_raises(Kamal::ConfigurationError) do
config.builder config_with_builder_option.builder
end end
end end
@@ -54,32 +77,32 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase
end end
test "setting gha cache" do test "setting gha cache" do
@deploy[:builder] = { "arch" => "amd64", "cache" => { "type" => "gha", "options" => "mode=max" } } @deploy_with_builder_option[:builder] = { "cache" => { "type" => "gha", "options" => "mode=max" } }
assert_equal "type=gha", config.builder.cache_from assert_equal "type=gha", config_with_builder_option.builder.cache_from
assert_equal "type=gha,mode=max", config.builder.cache_to assert_equal "type=gha,mode=max", config_with_builder_option.builder.cache_to
end end
test "setting registry cache" do test "setting registry cache" do
@deploy[:builder] = { "arch" => "amd64", "cache" => { "type" => "registry", "options" => "mode=max,image-manifest=true,oci-mediatypes=true" } } @deploy_with_builder_option[:builder] = { "cache" => { "type" => "registry", "options" => "mode=max,image-manifest=true,oci-mediatypes=true" } }
assert_equal "type=registry,ref=dhh/app-build-cache", config.builder.cache_from assert_equal "type=registry,ref=dhh/app-build-cache", config_with_builder_option.builder.cache_from
assert_equal "type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=dhh/app-build-cache", config.builder.cache_to assert_equal "type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=dhh/app-build-cache", config_with_builder_option.builder.cache_to
end end
test "setting registry cache when using a custom registry" do test "setting registry cache when using a custom registry" do
@deploy[:registry]["server"] = "registry.example.com" @deploy_with_builder_option[:registry]["server"] = "registry.example.com"
@deploy[:builder] = { "arch" => "amd64", "cache" => { "type" => "registry", "options" => "mode=max,image-manifest=true,oci-mediatypes=true" } } @deploy_with_builder_option[:builder] = { "cache" => { "type" => "registry", "options" => "mode=max,image-manifest=true,oci-mediatypes=true" } }
assert_equal "type=registry,ref=registry.example.com/dhh/app-build-cache", config.builder.cache_from assert_equal "type=registry,ref=registry.example.com/dhh/app-build-cache", config_with_builder_option.builder.cache_from
assert_equal "type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=registry.example.com/dhh/app-build-cache", config.builder.cache_to assert_equal "type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=registry.example.com/dhh/app-build-cache", config_with_builder_option.builder.cache_to
end end
test "setting registry cache with image" do test "setting registry cache with image" do
@deploy[:builder] = { "arch" => "amd64", "cache" => { "type" => "registry", "image" => "kamal", "options" => "mode=max" } } @deploy_with_builder_option[:builder] = { "cache" => { "type" => "registry", "image" => "kamal", "options" => "mode=max" } }
assert_equal "type=registry,ref=kamal", config.builder.cache_from assert_equal "type=registry,ref=kamal", config_with_builder_option.builder.cache_from
assert_equal "type=registry,mode=max,ref=kamal", config.builder.cache_to assert_equal "type=registry,mode=max,ref=kamal", config_with_builder_option.builder.cache_to
end end
test "args" do test "args" do
@@ -87,21 +110,19 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase
end end
test "setting args" do test "setting args" do
@deploy[:builder]["args"] = { "key" => "value" } @deploy_with_builder_option[:builder] = { "args" => { "key" => "value" } }
assert_equal({ "key" => "value" }, config.builder.args) assert_equal({ "key" => "value" }, config_with_builder_option.builder.args)
end end
test "secrets" do test "secrets" do
assert_equal({}, config.builder.secrets) assert_equal [], config.builder.secrets
end end
test "setting secrets" do test "setting secrets" do
with_test_secrets("secrets" => "GITHUB_TOKEN=secret123") do @deploy_with_builder_option[:builder] = { "secrets" => [ "GITHUB_TOKEN" ] }
@deploy[:builder]["secrets"] = [ "GITHUB_TOKEN" ]
assert_equal({ "GITHUB_TOKEN" => "secret123" }, config.builder.secrets) assert_equal [ "GITHUB_TOKEN" ], config_with_builder_option.builder.secrets
end
end end
test "dockerfile" do test "dockerfile" do
@@ -109,9 +130,9 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase
end end
test "setting dockerfile" do test "setting dockerfile" do
@deploy[:builder]["dockerfile"] = "Dockerfile.dev" @deploy_with_builder_option[:builder] = { "dockerfile" => "Dockerfile.dev" }
assert_equal "Dockerfile.dev", config.builder.dockerfile assert_equal "Dockerfile.dev", config_with_builder_option.builder.dockerfile
end end
test "context" do test "context" do
@@ -119,9 +140,9 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase
end end
test "setting context" do test "setting context" do
@deploy[:builder]["context"] = ".." @deploy_with_builder_option[:builder] = { "context" => ".." }
assert_equal "..", config.builder.context assert_equal "..", config_with_builder_option.builder.context
end end
test "ssh" do test "ssh" do
@@ -129,30 +150,17 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase
end end
test "setting ssh params" do test "setting ssh params" do
@deploy[:builder]["ssh"] = "default=$SSH_AUTH_SOCK" @deploy_with_builder_option[:builder] = { "ssh" => "default=$SSH_AUTH_SOCK" }
assert_equal "default=$SSH_AUTH_SOCK", config.builder.ssh assert_equal "default=$SSH_AUTH_SOCK", config_with_builder_option.builder.ssh
end
test "local disabled but no remote set" do
@deploy[:builder]["local"] = false
assert_raises(Kamal::ConfigurationError) do
config.builder
end
end
test "local disabled all arches are remote" do
@deploy[:builder]["local"] = false
@deploy[:builder]["remote"] = "ssh://root@192.168.0.1"
@deploy[:builder]["arch"] = [ "amd64", "arm64" ]
assert_equal [], config.builder.local_arches
assert_equal [ "amd64", "arm64" ], config.builder.remote_arches
end end
private private
def config def config
Kamal::Configuration.new(@deploy) Kamal::Configuration.new(@deploy)
end end
def config_with_builder_option
Kamal::Configuration.new(@deploy_with_builder_option)
end
end end

View File

@@ -5,7 +5,6 @@ class ConfigurationEnvTagsTest < ActiveSupport::TestCase
@deploy = { @deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ { "1.1.1.1" => "odd" }, { "1.1.1.2" => "even" }, { "1.1.1.3" => [ "odd", "three" ] } ], servers: [ { "1.1.1.1" => "odd" }, { "1.1.1.2" => "even" }, { "1.1.1.3" => [ "odd", "three" ] } ],
builder: { "arch" => "amd64" },
env: { env: {
"clear" => { "REDIS_URL" => "redis://x/y", "THREE" => "false" }, "clear" => { "REDIS_URL" => "redis://x/y", "THREE" => "false" },
"tags" => { "tags" => {
@@ -65,7 +64,6 @@ class ConfigurationEnvTagsTest < ActiveSupport::TestCase
deploy = { deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ { "1.1.1.1" => [ "first", "second" ] } ], servers: [ { "1.1.1.1" => [ "first", "second" ] } ],
builder: { "arch" => "amd64" },
env: { env: {
"tags" => { "tags" => {
"first" => { "TYPE" => "first" }, "first" => { "TYPE" => "first" },
@@ -79,28 +77,28 @@ class ConfigurationEnvTagsTest < ActiveSupport::TestCase
end end
test "tag secret env" do test "tag secret env" do
with_test_secrets("secrets" => "PASSWORD=hello") do ENV["PASSWORD"] = "hello"
deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, deploy = {
servers: [ { "1.1.1.1" => "secrets" } ], service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
builder: { "arch" => "amd64" }, servers: [ { "1.1.1.1" => "secrets" } ],
env: { env: {
"tags" => { "tags" => {
"secrets" => { "secret" => [ "PASSWORD" ] } "secrets" => { "secret" => [ "PASSWORD" ] }
}
} }
} }
}
config = Kamal::Configuration.new(deploy) config = Kamal::Configuration.new(deploy)
assert_equal "hello", config.role("web").env("1.1.1.1").secrets["PASSWORD"] assert_equal "hello", config.role("web").env("1.1.1.1").secrets["PASSWORD"]
end ensure
ENV.delete "PASSWORD"
end end
test "tag clear env" do test "tag clear env" do
deploy = { deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ { "1.1.1.1" => "clearly" } ], servers: [ { "1.1.1.1" => "clearly" } ],
builder: { "arch" => "amd64" },
env: { env: {
"tags" => { "tags" => {
"clearly" => { "clear" => { "FOO" => "bar" } } "clearly" => { "clear" => { "FOO" => "bar" } }

View File

@@ -6,21 +6,27 @@ class ConfigurationEnvTest < ActiveSupport::TestCase
test "simple" do test "simple" do
assert_config \ assert_config \
config: { "foo" => "bar", "baz" => "haz" }, config: { "foo" => "bar", "baz" => "haz" },
clear: { "foo" => "bar", "baz" => "haz" } clear: { "foo" => "bar", "baz" => "haz" },
secrets: {}
end end
test "clear" do test "clear" do
assert_config \ assert_config \
config: { "clear" => { "foo" => "bar", "baz" => "haz" } }, config: { "clear" => { "foo" => "bar", "baz" => "haz" } },
clear: { "foo" => "bar", "baz" => "haz" } clear: { "foo" => "bar", "baz" => "haz" },
secrets: {}
end end
test "secret" do test "secret" do
with_test_secrets("secrets" => "PASSWORD=hello") do ENV["PASSWORD"] = "hello"
assert_config \ env = Kamal::Configuration::Env.new config: { "secret" => [ "PASSWORD" ] }
config: { "secret" => [ "PASSWORD" ] },
secrets: { "PASSWORD" => "hello" } assert_config \
end config: { "secret" => [ "PASSWORD" ] },
clear: {},
secrets: { "PASSWORD" => "hello" }
ensure
ENV.delete "PASSWORD"
end end
test "missing secret" do test "missing secret" do
@@ -28,32 +34,41 @@ class ConfigurationEnvTest < ActiveSupport::TestCase
"secret" => [ "PASSWORD" ] "secret" => [ "PASSWORD" ]
} }
assert_raises(Kamal::ConfigurationError) { Kamal::Configuration::Env.new(config: { "secret" => [ "PASSWORD" ] }, secrets: Kamal::Secrets.new).secrets_io } assert_raises(KeyError) { Kamal::Configuration::Env.new(config: { "secret" => [ "PASSWORD" ] }).secrets }
end end
test "secret and clear" do test "secret and clear" do
with_test_secrets("secrets" => "PASSWORD=hello") do ENV["PASSWORD"] = "hello"
config = { config = {
"secret" => [ "PASSWORD" ], "secret" => [ "PASSWORD" ],
"clear" => { "clear" => {
"foo" => "bar", "foo" => "bar",
"baz" => "haz" "baz" => "haz"
}
} }
}
assert_config \ assert_config \
config: config, config: config,
clear: { "foo" => "bar", "baz" => "haz" }, clear: { "foo" => "bar", "baz" => "haz" },
secrets: { "PASSWORD" => "hello" } secrets: { "PASSWORD" => "hello" }
end ensure
ENV.delete "PASSWORD"
end
test "stringIO conversion" do
env = {
"foo" => "bar",
"baz" => "haz"
}
assert_equal "foo=bar\nbaz=haz\n", \
StringIO.new(Kamal::EnvFile.new(env)).read
end end
private private
def assert_config(config:, clear: {}, secrets: {}) def assert_config(config:, clear:, secrets:)
env = Kamal::Configuration::Env.new config: config, secrets: Kamal::Secrets.new env = Kamal::Configuration::Env.new config: config, secrets_file: "secrets.env"
expected_clear_args = clear.to_a.flat_map { |key, value| [ "--env", "#{key}=\"#{value}\"" ] } assert_equal clear, env.clear
assert_equal expected_clear_args, env.clear_args.map(&:to_s) #  to_s removes the redactions assert_equal secrets, env.secrets
expected_secrets = secrets.to_a.flat_map { |key, value| "#{key}=#{value}" }.join("\n") + "\n"
assert_equal expected_secrets, env.secrets_io.string
end end
end end

View File

@@ -1,25 +0,0 @@
require "test_helper"
class ConfigurationEnvTest < ActiveSupport::TestCase
setup do
@deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
builder: { "arch" => "amd64" }, servers: [ "1.1.1.1" ]
}
end
test "ssl with host" do
@deploy[:proxy] = { "ssl" => true, "host" => "example.com" }
assert_equal true, config.proxy.ssl?
end
test "ssl with no host" do
@deploy[:proxy] = { "ssl" => true }
assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? }
end
private
def config
Kamal::Configuration.new(@deploy)
end
end

View File

@@ -5,10 +5,11 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
@deploy = { @deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ "1.1.1.1", "1.1.1.2" ], servers: [ "1.1.1.1", "1.1.1.2" ],
builder: { "arch" => "amd64" },
env: { "REDIS_URL" => "redis://x/y" } env: { "REDIS_URL" => "redis://x/y" }
} }
@config = Kamal::Configuration.new(@deploy)
@deploy_with_roles = @deploy.dup.merge({ @deploy_with_roles = @deploy.dup.merge({
servers: { servers: {
"web" => [ "1.1.1.1", "1.1.1.2" ], "web" => [ "1.1.1.1", "1.1.1.2" ],
@@ -22,29 +23,31 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
} }
} }
}) })
@config_with_roles = Kamal::Configuration.new(@deploy_with_roles)
end end
test "hosts" do test "hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2" ], config.role(:web).hosts assert_equal [ "1.1.1.1", "1.1.1.2" ], @config.role(:web).hosts
assert_equal [ "1.1.1.3", "1.1.1.4" ], config_with_roles.role(:workers).hosts assert_equal [ "1.1.1.3", "1.1.1.4" ], @config_with_roles.role(:workers).hosts
end end
test "cmd" do test "cmd" do
assert_nil config.role(:web).cmd assert_nil @config.role(:web).cmd
assert_equal "bin/jobs", config_with_roles.role(:workers).cmd assert_equal "bin/jobs", @config_with_roles.role(:workers).cmd
end end
test "label args" do test "label args" do
assert_equal [ "--label", "service=\"app\"", "--label", "role=\"workers\"", "--label", "destination" ], config_with_roles.role(:workers).label_args assert_equal [ "--label", "service=\"app\"", "--label", "role=\"workers\"", "--label", "destination" ], @config_with_roles.role(:workers).label_args
end end
test "special label args for web" do test "special label args for web" do
assert_equal [ "--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\"" ], config.role(:web).label_args assert_equal [ "--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\"" ], @config.role(:web).label_args
end end
test "custom labels" do test "custom labels" do
@deploy[:labels] = { "my.custom.label" => "50" } @deploy[:labels] = { "my.custom.label" => "50" }
assert_equal "50", config.role(:web).labels["my.custom.label"] assert_equal "50", @config.role(:web).labels["my.custom.label"]
end end
test "custom labels via role specialization" do test "custom labels via role specialization" do
@@ -55,7 +58,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
test "overwriting default traefik label" do test "overwriting default traefik label" do
@deploy[:labels] = { "traefik.http.routers.app-web.rule" => "\"Host(\\`example.com\\`) || (Host(\\`example.org\\`) && Path(\\`/traefik\\`))\"" } @deploy[:labels] = { "traefik.http.routers.app-web.rule" => "\"Host(\\`example.com\\`) || (Host(\\`example.org\\`) && Path(\\`/traefik\\`))\"" }
assert_equal "\"Host(\\`example.com\\`) || (Host(\\`example.org\\`) && Path(\\`/traefik\\`))\"", config.role(:web).labels["traefik.http.routers.app-web.rule"] assert_equal "\"Host(\\`example.com\\`) || (Host(\\`example.org\\`) && Path(\\`/traefik\\`))\"", @config.role(:web).labels["traefik.http.routers.app-web.rule"]
end end
test "default traefik label on non-web role" do test "default traefik label on non-web role" do
@@ -67,165 +70,166 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
end end
test "env overwritten by role" do test "env overwritten by role" do
assert_equal "redis://a/b", config_with_roles.role(:workers).env("1.1.1.3").clear["REDIS_URL"] assert_equal "redis://a/b", @config_with_roles.role(:workers).env("1.1.1.3").clear["REDIS_URL"]
assert_equal \ assert_equal "\n", @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string
[ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/env/roles/app-workers.env" ], assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3")
config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s)
assert_equal \
"\n",
config_with_roles.role(:workers).secrets_io("1.1.1.3").read
end end
test "container name" do test "container name" do
ENV["VERSION"] = "12345" ENV["VERSION"] = "12345"
assert_equal "app-workers-12345", config_with_roles.role(:workers).container_name assert_equal "app-workers-12345", @config_with_roles.role(:workers).container_name
assert_equal "app-web-12345", config_with_roles.role(:web).container_name assert_equal "app-web-12345", @config_with_roles.role(:web).container_name
ensure ensure
ENV.delete("VERSION") ENV.delete("VERSION")
end end
test "env args" do test "env args" do
assert_equal \ assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3")
[ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/env/roles/app-workers.env" ],
config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s)
assert_equal \
"\n",
config_with_roles.role(:workers).secrets_io("1.1.1.3").read
end end
test "env secret overwritten by role" do test "env secret overwritten by role" do
with_test_secrets("secrets" => "REDIS_PASSWORD=secret456\nDB_PASSWORD=secret&\"123") do @deploy_with_roles[:env] = {
@deploy_with_roles[:env] = { "clear" => {
"clear" => { "REDIS_URL" => "redis://a/b"
"REDIS_URL" => "redis://a/b" },
}, "secret" => [
"secret" => [ "REDIS_PASSWORD"
"REDIS_PASSWORD" ]
] }
}
@deploy_with_roles[:servers]["workers"]["env"] = { @deploy_with_roles[:servers]["workers"]["env"] = {
"clear" => { "clear" => {
"REDIS_URL" => "redis://a/b", "REDIS_URL" => "redis://a/b",
"WEB_CONCURRENCY" => "4" "WEB_CONCURRENCY" => "4"
}, },
"secret" => [ "secret" => [
"DB_PASSWORD" "DB_PASSWORD"
] ]
} }
assert_equal \ ENV["REDIS_PASSWORD"] = "secret456"
[ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/env/roles/app-workers.env" ], ENV["DB_PASSWORD"] = "secret&\"123"
config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s)
assert_equal \ expected_secrets_file = <<~ENV
"REDIS_PASSWORD=secret456\nDB_PASSWORD=secret&\"123\n", REDIS_PASSWORD=secret456
config_with_roles.role(:workers).secrets_io("1.1.1.3").read DB_PASSWORD=secret&\"123
end ENV
assert_equal expected_secrets_file, Kamal::Configuration.new(@deploy_with_roles).role(:workers).env("1.1.1.3").secrets_io.string
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3")
ensure
ENV["REDIS_PASSWORD"] = nil
ENV["DB_PASSWORD"] = nil
end end
test "env secrets only in role" do test "env secrets only in role" do
with_test_secrets("secrets" => "DB_PASSWORD=secret123") do @deploy_with_roles[:servers]["workers"]["env"] = {
@deploy_with_roles[:servers]["workers"]["env"] = { "clear" => {
"clear" => { "REDIS_URL" => "redis://a/b",
"REDIS_URL" => "redis://a/b", "WEB_CONCURRENCY" => "4"
"WEB_CONCURRENCY" => "4" },
}, "secret" => [
"secret" => [ "DB_PASSWORD"
"DB_PASSWORD" ]
] }
}
assert_equal \ ENV["DB_PASSWORD"] = "secret123"
[ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/env/roles/app-workers.env" ],
config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s)
assert_equal \ expected_secrets_file = <<~ENV
"DB_PASSWORD=secret123\n", DB_PASSWORD=secret123
config_with_roles.role(:workers).secrets_io("1.1.1.3").read ENV
end
assert_equal expected_secrets_file, Kamal::Configuration.new(@deploy_with_roles).role(:workers).env("1.1.1.3").secrets_io.string
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3")
ensure
ENV["DB_PASSWORD"] = nil
end end
test "env secrets only at top level" do test "env secrets only at top level" do
with_test_secrets("secrets" => "REDIS_PASSWORD=secret456") do @deploy_with_roles[:env] = {
@deploy_with_roles[:env] = { "clear" => {
"clear" => { "REDIS_URL" => "redis://a/b"
"REDIS_URL" => "redis://a/b" },
}, "secret" => [
"secret" => [ "REDIS_PASSWORD"
"REDIS_PASSWORD" ]
] }
}
assert_equal \ ENV["REDIS_PASSWORD"] = "secret456"
[ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/env/roles/app-workers.env" ],
config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s)
assert_equal \ expected_secrets_file = <<~ENV
"REDIS_PASSWORD=secret456\n", REDIS_PASSWORD=secret456
config_with_roles.role(:workers).secrets_io("1.1.1.3").read ENV
end
assert_equal expected_secrets_file, Kamal::Configuration.new(@deploy_with_roles).role(:workers).env("1.1.1.3").secrets_io.string
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3")
ensure
ENV["REDIS_PASSWORD"] = nil
end end
test "env overwritten by role with secrets" do test "env overwritten by role with secrets" do
with_test_secrets("secrets" => "REDIS_PASSWORD=secret456") do @deploy_with_roles[:env] = {
@deploy_with_roles[:env] = { "clear" => {
"clear" => { "REDIS_URL" => "redis://a/b"
"REDIS_URL" => "redis://a/b" },
}, "secret" => [
"secret" => [ "REDIS_PASSWORD"
"REDIS_PASSWORD" ]
] }
@deploy_with_roles[:servers]["workers"]["env"] = {
"clear" => {
"REDIS_URL" => "redis://c/d"
} }
}
@deploy_with_roles[:servers]["workers"]["env"] = { ENV["REDIS_PASSWORD"] = "secret456"
"clear" => {
"REDIS_URL" => "redis://c/d"
}
}
assert_equal \ expected_secrets_file = <<~ENV
[ "--env", "REDIS_URL=\"redis://c/d\"", "--env-file", ".kamal/env/roles/app-workers.env" ], REDIS_PASSWORD=secret456
config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) ENV
assert_equal \ config = Kamal::Configuration.new(@deploy_with_roles)
"REDIS_PASSWORD=secret456\n", assert_equal expected_secrets_file, config.role(:workers).env("1.1.1.3").secrets_io.string
config_with_roles.role(:workers).secrets_io("1.1.1.3").read assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://c/d\"" ], config.role(:workers).env_args("1.1.1.3")
end ensure
ENV["REDIS_PASSWORD"] = nil
end
test "env secrets_file" do
assert_equal ".kamal/env/roles/app-workers.env", @config_with_roles.role(:workers).env("1.1.1.3").secrets_file
end end
test "uses cord" do test "uses cord" do
assert config_with_roles.role(:web).uses_cord? assert @config_with_roles.role(:web).uses_cord?
assert_not config_with_roles.role(:workers).uses_cord? assert_not @config_with_roles.role(:workers).uses_cord?
end end
test "cord host file" do test "cord host file" do
assert_match %r{.kamal/cords/app-web-[0-9a-f]{32}/cord}, config_with_roles.role(:web).cord_host_file assert_match %r{.kamal/cords/app-web-[0-9a-f]{32}/cord}, @config_with_roles.role(:web).cord_host_file
end end
test "cord volume" do test "cord volume" do
assert_equal "/tmp/kamal-cord", config_with_roles.role(:web).cord_volume.container_path assert_equal "/tmp/kamal-cord", @config_with_roles.role(:web).cord_volume.container_path
assert_match %r{.kamal/cords/app-web-[0-9a-f]{32}}, config_with_roles.role(:web).cord_volume.host_path assert_match %r{.kamal/cords/app-web-[0-9a-f]{32}}, @config_with_roles.role(:web).cord_volume.host_path
assert_equal "--volume", config_with_roles.role(:web).cord_volume.docker_args[0] assert_equal "--volume", @config_with_roles.role(:web).cord_volume.docker_args[0]
assert_match %r{\$\(pwd\)/.kamal/cords/app-web-[0-9a-f]{32}:/tmp/kamal-cord}, config_with_roles.role(:web).cord_volume.docker_args[1] assert_match %r{\$\(pwd\)/.kamal/cords/app-web-[0-9a-f]{32}:/tmp/kamal-cord}, @config_with_roles.role(:web).cord_volume.docker_args[1]
end end
test "cord container file" do test "cord container file" do
assert_equal "/tmp/kamal-cord/cord", config_with_roles.role(:web).cord_container_file assert_equal "/tmp/kamal-cord/cord", @config_with_roles.role(:web).cord_container_file
end end
test "asset path and volume args" do test "asset path and volume args" do
ENV["VERSION"] = "12345" ENV["VERSION"] = "12345"
assert_nil config_with_roles.role(:web).asset_volume_args assert_nil @config_with_roles.role(:web).asset_volume_args
assert_nil config_with_roles.role(:workers).asset_volume_args assert_nil @config_with_roles.role(:workers).asset_volume_args
assert_nil config_with_roles.role(:web).asset_path assert_nil @config_with_roles.role(:web).asset_path
assert_nil config_with_roles.role(:workers).asset_path assert_nil @config_with_roles.role(:workers).asset_path
assert_not config_with_roles.role(:web).assets? assert_not @config_with_roles.role(:web).assets?
assert_not config_with_roles.role(:workers).assets? assert_not @config_with_roles.role(:workers).assets?
config_with_assets = Kamal::Configuration.new(@deploy_with_roles.dup.tap { |c| config_with_assets = Kamal::Configuration.new(@deploy_with_roles.dup.tap { |c|
c[:asset_path] = "foo" c[:asset_path] = "foo"
@@ -253,26 +257,17 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
test "asset extracted path" do test "asset extracted path" do
ENV["VERSION"] = "12345" ENV["VERSION"] = "12345"
assert_equal ".kamal/assets/extracted/app-web-12345", config_with_roles.role(:web).asset_extracted_path assert_equal ".kamal/assets/extracted/app-web-12345", @config_with_roles.role(:web).asset_extracted_path
assert_equal ".kamal/assets/extracted/app-workers-12345", config_with_roles.role(:workers).asset_extracted_path assert_equal ".kamal/assets/extracted/app-workers-12345", @config_with_roles.role(:workers).asset_extracted_path
ensure ensure
ENV.delete("VERSION") ENV.delete("VERSION")
end end
test "asset volume path" do test "asset volume path" do
ENV["VERSION"] = "12345" ENV["VERSION"] = "12345"
assert_equal ".kamal/assets/volumes/app-web-12345", config_with_roles.role(:web).asset_volume_path assert_equal ".kamal/assets/volumes/app-web-12345", @config_with_roles.role(:web).asset_volume_path
assert_equal ".kamal/assets/volumes/app-workers-12345", config_with_roles.role(:workers).asset_volume_path assert_equal ".kamal/assets/volumes/app-workers-12345", @config_with_roles.role(:workers).asset_volume_path
ensure ensure
ENV.delete("VERSION") ENV.delete("VERSION")
end end
private
def config
Kamal::Configuration.new(@deploy)
end
def config_with_roles
Kamal::Configuration.new(@deploy_with_roles)
end
end end

View File

@@ -5,7 +5,6 @@ class ConfigurationSshTest < ActiveSupport::TestCase
@deploy = { @deploy = {
service: "app", image: "dhh/app", service: "app", image: "dhh/app",
registry: { "username" => "dhh", "password" => "secret" }, registry: { "username" => "dhh", "password" => "secret" },
builder: { "arch" => "amd64" },
env: { "REDIS_URL" => "redis://x/y" }, env: { "REDIS_URL" => "redis://x/y" },
servers: [ "1.1.1.1", "1.1.1.2" ], servers: [ "1.1.1.1", "1.1.1.2" ],
volumes: [ "/local/path:/container/path" ] volumes: [ "/local/path:/container/path" ]

View File

@@ -6,7 +6,6 @@ class ConfigurationSshkitTest < ActiveSupport::TestCase
service: "app", image: "dhh/app", service: "app", image: "dhh/app",
registry: { "username" => "dhh", "password" => "secret" }, registry: { "username" => "dhh", "password" => "secret" },
env: { "REDIS_URL" => "redis://x/y" }, env: { "REDIS_URL" => "redis://x/y" },
builder: { "arch" => "amd64" },
servers: [ "1.1.1.1", "1.1.1.2" ], servers: [ "1.1.1.1", "1.1.1.2" ],
volumes: [ "/local/path:/container/path" ] volumes: [ "/local/path:/container/path" ]
} }

View File

@@ -90,8 +90,10 @@ class ConfigurationValidationTest < ActiveSupport::TestCase
test "builder" do test "builder" do
assert_error "builder: unknown key: foo", builder: { "foo" => "bar" } assert_error "builder: unknown key: foo", builder: { "foo" => "bar" }
assert_error "builder/remote: should be a string", builder: { "remote" => { "foo" => "bar" } } assert_error "builder/remote: should be a hash", builder: { "remote" => true }
assert_error "builder/arch: should be an array or a string", builder: { "arch" => {} } assert_error "builder/remote: unknown key: foo", builder: { "remote" => { "foo" => "bar" } }
assert_error "builder/local: unknown key: foo", builder: { "local" => { "foo" => "bar" } }
assert_error "builder/remote/arch: should be a string", builder: { "remote" => { "arch" => [] } }
assert_error "builder/args: should be a hash", builder: { "args" => [ "foo" ] } assert_error "builder/args: should be a hash", builder: { "args" => [ "foo" ] }
assert_error "builder/cache/options: should be a string", builder: { "cache" => { "options" => [] } } assert_error "builder/cache/options: should be a string", builder: { "cache" => { "options" => [] } }
end end
@@ -101,7 +103,6 @@ class ConfigurationValidationTest < ActiveSupport::TestCase
valid_config = { valid_config = {
service: "app", service: "app",
image: "app", image: "app",
builder: { "arch" => "amd64" },
registry: { "username" => "user", "password" => "secret" }, registry: { "username" => "user", "password" => "secret" },
servers: [ "1.1.1.1" ] servers: [ "1.1.1.1" ]
} }

View File

@@ -8,7 +8,6 @@ class ConfigurationTest < ActiveSupport::TestCase
@deploy = { @deploy = {
service: "app", image: "dhh/app", service: "app", image: "dhh/app",
registry: { "username" => "dhh", "password" => "secret" }, registry: { "username" => "dhh", "password" => "secret" },
builder: { "arch" => "amd64" },
env: { "REDIS_URL" => "redis://x/y" }, env: { "REDIS_URL" => "redis://x/y" },
servers: [ "1.1.1.1", "1.1.1.2" ], servers: [ "1.1.1.1", "1.1.1.2" ],
volumes: [ "/local/path:/container/path" ] volumes: [ "/local/path:/container/path" ]
@@ -122,7 +121,7 @@ class ConfigurationTest < ActiveSupport::TestCase
test "version from uncommitted context" do test "version from uncommitted context" do
ENV.delete("VERSION") ENV.delete("VERSION")
config = Kamal::Configuration.new(@deploy.tap { |c| c[:builder]["context"] = "." }) config = Kamal::Configuration.new(@deploy.tap { |c| c[:builder] = { "context" => "." } })
Kamal::Git.expects(:revision).returns("git-version") Kamal::Git.expects(:revision).returns("git-version")
Kamal::Git.expects(:uncommitted_changes).returns("M file\n") Kamal::Git.expects(:uncommitted_changes).returns("M file\n")
@@ -268,7 +267,7 @@ class ConfigurationTest < ActiveSupport::TestCase
ssh_options: { user: "root", port: 22, log_level: :fatal, keepalive: true, keepalive_interval: 30 }, ssh_options: { user: "root", port: 22, log_level: :fatal, keepalive: true, keepalive_interval: 30 },
sshkit: {}, sshkit: {},
volume_args: [ "--volume", "/local/path:/container/path" ], volume_args: [ "--volume", "/local/path:/container/path" ],
builder: { "arch" => "amd64" }, builder: {},
logging: [ "--log-opt", "max-size=\"10m\"" ], logging: [ "--log-opt", "max-size=\"10m\"" ],
healthcheck: { "cmd"=>"curl -f http://localhost:3000/up || exit 1", "interval" => "1s", "path"=>"/up", "port"=>3000, "max_attempts" => 7, "cord" => "/tmp/kamal-cord", "log_lines" => 50 } } healthcheck: { "cmd"=>"curl -f http://localhost:3000/up || exit 1", "interval" => "1s", "path"=>"/up", "port"=>3000, "max_attempts" => 7, "cord" => "/tmp/kamal-cord", "log_lines" => 50 } }
@@ -294,11 +293,17 @@ class ConfigurationTest < ActiveSupport::TestCase
test "run directory" do test "run directory" do
config = Kamal::Configuration.new(@deploy) config = Kamal::Configuration.new(@deploy)
assert_equal ".kamal", config.run_directory assert_equal ".kamal", config.run_directory
config = Kamal::Configuration.new(@deploy.merge!(run_directory: "/root/kamal"))
assert_equal "/root/kamal", config.run_directory
end end
test "run directory as docker volume" do test "run directory as docker volume" do
config = Kamal::Configuration.new(@deploy) config = Kamal::Configuration.new(@deploy)
assert_equal "$(pwd)/.kamal", config.run_directory_as_docker_volume assert_equal "$(pwd)/.kamal", config.run_directory_as_docker_volume
config = Kamal::Configuration.new(@deploy.merge!(run_directory: "/root/kamal"))
assert_equal "/root/kamal", config.run_directory_as_docker_volume
end end
test "run id" do test "run id" do

View File

@@ -9,5 +9,3 @@ registry:
server: registry.digitalocean.com server: registry.digitalocean.com
username: <%= "my-user" %> username: <%= "my-user" %>
password: <%= "my-password" %> password: <%= "my-password" %>
builder:
arch: amd64

View File

@@ -4,5 +4,3 @@ registry:
server: registry.digitalocean.com server: registry.digitalocean.com
username: <%= "my-user" %> username: <%= "my-user" %>
password: <%= "my-password" %> password: <%= "my-password" %>
builder:
arch: amd64

View File

@@ -4,6 +4,4 @@ registry:
server: registry.digitalocean.com server: registry.digitalocean.com
username: <%= "my-user" %> username: <%= "my-user" %>
password: <%= "my-password" %> password: <%= "my-password" %>
builder:
arch: amd64
require_destination: true require_destination: true

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