diff --git a/Gemfile.lock b/Gemfile.lock index 8a8a1097..ed0d8a8b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - kamal (2.0.0.alpha) + kamal (2.0.0.rc2) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) diff --git a/README.md b/README.md index 9b95066f..924e1e27 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Kamal: Deploy web apps anywhere -From bare metal to cloud VMs, deploy web apps anywhere with zero downtime. Kamal has the dynamic reverse-proxy Traefik hold requests while a new app container is started and the old one is stopped. Works seamlessly across multiple hosts, using SSHKit to execute commands. Originally built for Rails apps, Kamal will work with any type of web app that can be containerized with Docker. +From bare metal to cloud VMs, deploy web apps anywhere with zero downtime. Kamal uses [kamal-proxy](https://github.com/basecamp/kamal-proxy) to seamlessly switch requests between containers. Works seamlessly across multiple servers, using SSHKit to execute commands. Originally built for Rails apps, Kamal will work with any type of web app that can be containerized with Docker. ➡️ See [kamal-deploy.org](https://kamal-deploy.org) for documentation on [installation](https://kamal-deploy.org/docs/installation), [configuration](https://kamal-deploy.org/docs/configuration), and [commands](https://kamal-deploy.org/docs/commands). diff --git a/bin/docs b/bin/docs index a8731ce2..b76872d3 100755 --- a/bin/docs +++ b/bin/docs @@ -22,14 +22,13 @@ DOCS = { "builder" => "Builders", "configuration" => "Configuration overview", "env" => "Environment variables", - "healthcheck" => "Healthchecks", "logging" => "Logging", + "proxy" => "Proxy", "registry" => "Docker Registry", "role" => "Roles", "servers" => "Servers", "ssh" => "SSH", - "sshkit" => "SSHKit", - "traefik" => "Traefik" + "sshkit" => "SSHKit" } class DocWriter diff --git a/lib/kamal/cli.rb b/lib/kamal/cli.rb index 6772556e..dc35c403 100644 --- a/lib/kamal/cli.rb +++ b/lib/kamal/cli.rb @@ -1,4 +1,5 @@ module Kamal::Cli + class BootError < StandardError; end class HookError < StandardError; end class LockError < StandardError; end end diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb index 2bf9a786..14114a25 100644 --- a/lib/kamal/cli/accessory.rb +++ b/lib/kamal/cli/accessory.rb @@ -1,16 +1,17 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base desc "boot [NAME]", "Boot new accessory service on host (use NAME=all to boot all accessories)" - def boot(name, login: true) + def boot(name, prepare: true) with_lock do if name == "all" KAMAL.accessory_names.each { |accessory_name| boot(accessory_name) } else + prepare(name) if prepare + with_accessory(name) do |accessory, hosts| directories(name) upload(name) on(hosts) do - execute *KAMAL.registry.login if login execute *KAMAL.auditor.record("Booted #{name} accessory"), verbosity: :debug execute *accessory.ensure_env_directory upload! accessory.secrets_io, accessory.secrets_path, mode: "0600" @@ -57,15 +58,10 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base if name == "all" KAMAL.accessory_names.each { |accessory_name| reboot(accessory_name) } else - with_accessory(name) do |accessory, hosts| - on(hosts) do - execute *KAMAL.registry.login - end - - stop(name) - remove_container(name) - boot(name, login: false) - end + prepare(name) + stop(name) + remove_container(name) + boot(name, prepare: false) end end end @@ -97,10 +93,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base desc "restart [NAME]", "Restart existing accessory container on host" def restart(name) with_lock do - with_accessory(name) do - stop(name) - start(name) - end + stop(name) + start(name) end end @@ -153,23 +147,25 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" option :grep_options, aliases: "-o", desc: "Additional options supplied to grep" option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)" + option :skip_timestamps, type: :boolean, aliases: "-T", desc: "Skip appending timestamps to logging output" def logs(name) with_accessory(name) do |accessory, hosts| grep = options[:grep] grep_options = options[:grep_options] + timestamps = !options[:skip_timestamps] if options[:follow] run_locally do info "Following logs on #{hosts}..." - info accessory.follow_logs(grep: grep, grep_options: grep_options) - exec accessory.follow_logs(grep: grep, grep_options: grep_options) + info accessory.follow_logs(timestamps: timestamps, grep: grep, grep_options: grep_options) + exec accessory.follow_logs(timestamps: timestamps, grep: grep, grep_options: grep_options) end else since = options[:since] lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set on(hosts) do - puts capture_with_info(*accessory.logs(since: since, lines: lines, grep: grep, grep_options: grep_options)) + puts capture_with_info(*accessory.logs(timestamps: timestamps, since: since, lines: lines, grep: grep, grep_options: grep_options)) end end end @@ -224,6 +220,25 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base end end + desc "upgrade", "Upgrade accessories from Kamal 1.x to 2.0 (restart them in 'kamal' network)" + option :rolling, type: :boolean, default: false, desc: "Upgrade one host at a time" + option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" + def upgrade(name) + confirming "This will restart all accessories" do + with_lock do + host_groups = options[:rolling] ? KAMAL.accessory_hosts : [ KAMAL.accessory_hosts ] + host_groups.each do |hosts| + host_list = Array(hosts).join(",") + KAMAL.with_specific_hosts(hosts) do + say "Upgrading #{name} accessories on #{host_list}...", :magenta + reboot name + say "Upgraded #{name} accessories on #{host_list}...", :magenta + end + end + end + end + end + private def with_accessory(name) if KAMAL.config.accessory(name) @@ -251,11 +266,20 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base end def remove_accessory(name) - with_accessory(name) do - stop(name) - remove_container(name) - remove_image(name) - remove_service_directory(name) + stop(name) + remove_container(name) + remove_image(name) + remove_service_directory(name) + end + + 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 diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index ce21b2cf..7c77a7cf 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -4,7 +4,7 @@ class Kamal::Cli::App < Kamal::Cli::Base with_lock do say "Get most recent version available as an image...", :magenta unless options[:version] using_version(version_or_latest) do |version| - say "Start container with version #{version} using a #{KAMAL.config.readiness_delay}s readiness delay (or reboot if already running)...", :magenta + say "Start container with version #{version} (or reboot if already running)...", :magenta # Assets are prepared in a separate step to ensure they are on all hosts before booting on(KAMAL.hosts) do @@ -38,8 +38,17 @@ class Kamal::Cli::App < Kamal::Cli::Base roles = KAMAL.roles_on(host) roles.each do |role| + app = KAMAL.app(role: role, host: host) execute *KAMAL.auditor.record("Started app version #{KAMAL.config.version}"), verbosity: :debug - execute *KAMAL.app(role: role, host: host).start, raise_on_non_zero_exit: false + execute *app.start, raise_on_non_zero_exit: false + + if role.running_proxy? + 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 + raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty? + + execute *app.deploy(target: endpoint) + end end end end @@ -52,8 +61,18 @@ class Kamal::Cli::App < Kamal::Cli::Base roles = KAMAL.roles_on(host) roles.each do |role| + app = KAMAL.app(role: role, host: host) execute *KAMAL.auditor.record("Stopped app", role: role), verbosity: :debug - execute *KAMAL.app(role: role, host: host).stop, raise_on_non_zero_exit: false + + if role.running_proxy? + 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 + if endpoint.present? + execute *app.remove(target: endpoint), raise_on_non_zero_exit: false + end + end + + execute *app.stop, raise_on_non_zero_exit: false end end end @@ -169,12 +188,14 @@ class Kamal::Cli::App < Kamal::Cli::Base option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" option :grep_options, aliases: "-o", desc: "Additional options supplied to grep" option :follow, aliases: "-f", desc: "Follow log on primary server (or specific host set by --hosts)" + option :skip_timestamps, type: :boolean, aliases: "-T", desc: "Skip appending timestamps to logging output" def logs # FIXME: Catch when app containers aren't running grep = options[:grep] grep_options = options[:grep_options] since = options[:since] + timestamps = !options[:skip_timestamps] if options[:follow] lines = options[:lines].presence || ((since || grep) ? nil : 10) # Default to 10 lines if since or grep isn't set @@ -186,8 +207,8 @@ class Kamal::Cli::App < Kamal::Cli::Base role = KAMAL.roles_on(KAMAL.primary_host).first app = KAMAL.app(role: role, host: host) - info app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep, grep_options: grep_options) - exec app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep, grep_options: grep_options) + info app.follow_logs(host: KAMAL.primary_host, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options) + exec app.follow_logs(host: KAMAL.primary_host, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options) end else lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set @@ -197,7 +218,7 @@ class Kamal::Cli::App < Kamal::Cli::Base roles.each do |role| begin - puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(since: since, lines: lines, grep: grep, grep_options: grep_options)) + puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(timestamps: timestamps, since: since, lines: lines, grep: grep, grep_options: grep_options)) rescue SSHKit::Command::Failed puts_by_host host, "Nothing found" end @@ -212,6 +233,7 @@ class Kamal::Cli::App < Kamal::Cli::Base stop remove_containers remove_images + remove_app_directory end end @@ -253,6 +275,20 @@ class Kamal::Cli::App < Kamal::Cli::Base end end + desc "remove_app_directory", "Remove the service directory from servers", hide: true + def remove_app_directory + with_lock do + on(KAMAL.hosts) do |host| + roles = KAMAL.roles_on(host) + + roles.each do |role| + execute *KAMAL.auditor.record("Removed #{KAMAL.config.app_directory} on all servers", role: role), verbosity: :debug + execute *KAMAL.server.remove_app_directory, raise_on_non_zero_exit: false + end + end + end + end + desc "version", "Show app version currently running on servers" def version on(KAMAL.hosts) do |host| diff --git a/lib/kamal/cli/app/boot.rb b/lib/kamal/cli/app/boot.rb index df3e6925..fd330c71 100644 --- a/lib/kamal/cli/app/boot.rb +++ b/lib/kamal/cli/app/boot.rb @@ -1,7 +1,7 @@ class Kamal::Cli::App::Boot attr_reader :host, :role, :version, :barrier, :sshkit delegate :execute, :capture_with_info, :capture_with_pretty_json, :info, :error, :upload!, to: :sshkit - delegate :uses_cord?, :assets?, :running_traefik?, to: :role + delegate :assets?, :running_proxy?, to: :role def initialize(host, role, sshkit, version, barrier) @host = host @@ -45,15 +45,22 @@ class Kamal::Cli::App::Boot def start_new_version audit "Booted app version #{version}" - - execute *app.tie_cord(role.cord_host_file) if uses_cord? hostname = "#{host.to_s[0...51].gsub(/\.+$/, '')}-#{SecureRandom.hex(6)}" execute *app.ensure_env_directory upload! role.secrets_io(host), role.secrets_path, mode: "0600" - execute *app.run(hostname: hostname) - Kamal::Cli::Healthcheck::Poller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) } + execute *app.run(hostname: hostname) + if running_proxy? + endpoint = capture_with_info(*app.container_id_for_version(version)).strip + raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty? + execute *app.deploy(target: endpoint) + else + Kamal::Cli::Healthcheck::Poller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) } + end + rescue => e + error "Failed to boot #{role} on #{host}" + raise e end def stop_new_version @@ -61,16 +68,7 @@ class Kamal::Cli::App::Boot end def stop_old_version(version) - if uses_cord? - cord = capture_with_info(*app.cord(version: version), raise_on_non_zero_exit: false).strip - if cord.present? - execute *app.cut_cord(cord) - Kamal::Cli::Healthcheck::Poller.wait_for_unhealthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) } - end - end - execute *app.stop(version: version), raise_on_non_zero_exit: false - execute *app.clean_up_assets if assets? end diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index 85815506..4aebfd90 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -101,7 +101,7 @@ module Kamal::Cli end def acquire_lock - ensure_run_and_locks_directory + ensure_run_directory raise_if_locked do say "Acquiring the deploy lock...", :magenta @@ -135,8 +135,10 @@ module Kamal::Cli details = { hosts: KAMAL.hosts.join(","), command: command, subcommand: subcommand } say "Running the #{hook} hook...", :magenta - run_locally do - execute *KAMAL.hook.run(hook, **details, **extra_details) + with_env KAMAL.hook.env(**details, **extra_details) do + run_locally do + execute *KAMAL.hook.run(hook) + end rescue SSHKit::Command::Failed => e raise HookError.new("Hook `#{hook}` failed:\n#{e.message}") end @@ -174,14 +176,23 @@ module Kamal::Cli instance_variable_get("@_invocations").first end - def ensure_run_and_locks_directory + def reset_invocation(cli_class) + instance_variable_get("@_invocations")[cli_class].pop + end + + def ensure_run_directory on(KAMAL.hosts) do execute(*KAMAL.server.ensure_run_directory) end + end - on(KAMAL.primary_host) do - execute(*KAMAL.lock.ensure_locks_directory) - end + def with_env(env) + current_env = ENV.to_h.dup + ENV.update(env) + yield + ensure + ENV.clear + ENV.update(current_env) end end end diff --git a/lib/kamal/cli/build.rb b/lib/kamal/cli/build.rb index 0347d18c..53ecb0bb 100644 --- a/lib/kamal/cli/build.rb +++ b/lib/kamal/cli/build.rb @@ -30,28 +30,30 @@ class Kamal::Cli::Build < Kamal::Cli::Base say "Building with uncommitted changes:\n #{uncommitted_changes}", :yellow end - run_locally do - begin - execute *KAMAL.builder.inspect_builder - rescue SSHKit::Command::Failed => e - if e.message =~ /(context not found|no builder|no compatible builder|does not exist)/ - 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)/ + with_env(KAMAL.config.builder.secrets) do + run_locally do + begin + execute *KAMAL.builder.inspect_builder + rescue SSHKit::Command::Failed => e + if e.message =~ /(context not found|no builder|no compatible builder|does not exist)/ + 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 + else + raise end - cli.create - else - raise end - end - # Get the command here to ensure the Dir.chdir doesn't interfere with it - push = KAMAL.builder.push + # Get the command here to ensure the Dir.chdir doesn't interfere with it + push = KAMAL.builder.push - KAMAL.with_verbosity(:debug) do - Dir.chdir(KAMAL.config.builder.build_directory) { execute *push, env: KAMAL.config.builder.secrets } + KAMAL.with_verbosity(:debug) do + Dir.chdir(KAMAL.config.builder.build_directory) { execute *push } + end end end end diff --git a/lib/kamal/cli/healthcheck/poller.rb b/lib/kamal/cli/healthcheck/poller.rb index 249a1f6b..fc3d0dec 100644 --- a/lib/kamal/cli/healthcheck/poller.rb +++ b/lib/kamal/cli/healthcheck/poller.rb @@ -1,26 +1,30 @@ module Kamal::Cli::Healthcheck::Poller extend self - TRAEFIK_UPDATE_DELAY = 5 - - - def wait_for_healthy(pause_after_ready: false, &block) + def wait_for_healthy(role, &block) attempt = 1 - max_attempts = KAMAL.config.healthcheck.max_attempts + timeout_at = Time.now + KAMAL.config.deploy_timeout + readiness_delay = KAMAL.config.readiness_delay begin - case status = block.call - when "healthy" - sleep TRAEFIK_UPDATE_DELAY if pause_after_ready - when "running" # No health check configured - sleep KAMAL.config.readiness_delay if pause_after_ready - else - raise Kamal::Cli::Healthcheck::Error, "container not ready (#{status})" + status = block.call + + if status == "running" + # Wait for the readiness delay and confirm it is still running + if readiness_delay > 0 + info "Container is running, waiting for readiness delay of #{readiness_delay} seconds" + sleep readiness_delay + status = block.call + end + end + + unless %w[ running healthy ].include?(status) + raise Kamal::Cli::Healthcheck::Error, "container not ready after #{KAMAL.config.deploy_timeout} seconds (#{status})" end rescue Kamal::Cli::Healthcheck::Error => e - if attempt <= max_attempts - info "#{e.message}, retrying in #{attempt}s (attempt #{attempt}/#{max_attempts})..." - sleep attempt + time_left = timeout_at - Time.now + if time_left > 0 + sleep [ attempt, time_left ].min attempt += 1 retry else @@ -31,31 +35,6 @@ module Kamal::Cli::Healthcheck::Poller info "Container is healthy!" end - def wait_for_unhealthy(pause_after_ready: false, &block) - attempt = 1 - max_attempts = KAMAL.config.healthcheck.max_attempts - - begin - case status = block.call - when "unhealthy" - sleep TRAEFIK_UPDATE_DELAY if pause_after_ready - else - raise Kamal::Cli::Healthcheck::Error, "container not unhealthy (#{status})" - end - rescue Kamal::Cli::Healthcheck::Error => e - if attempt <= max_attempts - info "#{e.message}, retrying in #{attempt}s (attempt #{attempt}/#{max_attempts})..." - sleep attempt - attempt += 1 - retry - else - raise - end - end - - info "Container is unhealthy!" - end - private def info(message) SSHKit.config.output.info(message) diff --git a/lib/kamal/cli/lock.rb b/lib/kamal/cli/lock.rb index 306c8a07..ceab1f27 100644 --- a/lib/kamal/cli/lock.rb +++ b/lib/kamal/cli/lock.rb @@ -12,7 +12,7 @@ class Kamal::Cli::Lock < Kamal::Cli::Base option :message, aliases: "-m", type: :string, desc: "A lock message", required: true def acquire message = options[:message] - ensure_run_and_locks_directory + ensure_run_directory raise_if_locked do on(KAMAL.primary_host) do diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 3bd6dc24..69735f2a 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -35,8 +35,8 @@ class Kamal::Cli::Main < Kamal::Cli::Base with_lock do run_hook "pre-deploy", secrets: true - say "Ensure Traefik is running...", :magenta - invoke "kamal:cli:traefik:boot", [], invoke_options + say "Ensure kamal-proxy is running...", :magenta + invoke "kamal:cli:proxy:boot", [], invoke_options say "Detect stale containers...", :magenta invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true) @@ -48,10 +48,10 @@ class Kamal::Cli::Main < Kamal::Cli::Base end end - run_hook "post-deploy", secrets: true, runtime: runtime.round + run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s 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 kamal-proxy, pruning, and registry login" option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" def redeploy runtime = print_runtime do @@ -75,7 +75,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base end end - run_hook "post-deploy", secrets: true, runtime: runtime.round + run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s end desc "rollback [VERSION]", "Rollback app to VERSION" @@ -99,12 +99,12 @@ class Kamal::Cli::Main < Kamal::Cli::Base end end - run_hook "post-deploy", secrets: true, runtime: runtime.round if rolled_back + run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s if rolled_back end desc "details", "Show details about all containers" def details - invoke "kamal:cli:traefik:details" + invoke "kamal:cli:proxy:details" invoke "kamal:cli:app:details" invoke "kamal:cli:accessory:details", [ "all" ] end @@ -123,7 +123,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base end end - desc "docs", "Show Kamal documentation for configuration setting" + desc "docs [SECTION]", "Show Kamal configuration documentation" def docs(section = nil) case section when NilClass @@ -176,19 +176,50 @@ class Kamal::Cli::Main < Kamal::Cli::Base end end - desc "remove", "Remove Traefik, app, accessories, and registry session from servers" + desc "remove", "Remove kamal-proxy, app, accessories, and registry session from servers" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" def remove confirming "This will remove all containers and images. Are you sure?" do with_lock do - invoke "kamal:cli:traefik:remove", [], options.without(:confirmed) invoke "kamal:cli:app:remove", [], options.without(:confirmed) + invoke "kamal:cli:proxy:remove", [], options.without(:confirmed) invoke "kamal:cli:accessory:remove", [ "all" ], options invoke "kamal:cli:registry:logout", [], options.without(:confirmed).merge(skip_local: true) end end end + desc "upgrade", "Upgrade from Kamal 1.x to 2.0" + option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" + option :rolling, type: :boolean, default: false, desc: "Upgrade one host at a time" + def upgrade + confirming "This will replace Traefik with kamal-proxy and restart all accessories" do + with_lock do + if options[:rolling] + (KAMAL.hosts | KAMAL.accessory_hosts).each do |host| + KAMAL.with_specific_hosts(host) do + say "Upgrading #{host}...", :magenta + if KAMAL.hosts.include?(host) + invoke "kamal:cli:proxy:upgrade", [], options.merge(confirmed: true, rolling: false) + reset_invocation(Kamal::Cli::Proxy) + end + if KAMAL.accessory_hosts.include?(host) + invoke "kamal:cli:accessory:upgrade", [ "all" ], options.merge(confirmed: true, rolling: false) + reset_invocation(Kamal::Cli::Accessory) + end + say "Upgraded #{host}", :magenta + end + end + else + say "Upgrading all hosts...", :magenta + invoke "kamal:cli:proxy:upgrade", [], options.merge(confirmed: true) + invoke "kamal:cli:accessory:upgrade", [ "all" ], options.merge(confirmed: true) + say "Upgraded all hosts", :magenta + end + end + end + end + desc "version", "Show Kamal version" def version puts Kamal::VERSION @@ -206,6 +237,9 @@ class Kamal::Cli::Main < Kamal::Cli::Base desc "lock", "Manage the deploy lock" subcommand "lock", Kamal::Cli::Lock + desc "proxy", "Manage kamal-proxy" + subcommand "proxy", Kamal::Cli::Proxy + desc "prune", "Prune old application images and containers" subcommand "prune", Kamal::Cli::Prune @@ -218,9 +252,6 @@ class Kamal::Cli::Main < Kamal::Cli::Base desc "server", "Bootstrap servers with curl and Docker" subcommand "server", Kamal::Cli::Server - desc "traefik", "Manage Traefik load balancer" - subcommand "traefik", Kamal::Cli::Traefik - private def container_available?(version) begin diff --git a/lib/kamal/cli/proxy.rb b/lib/kamal/cli/proxy.rb new file mode 100644 index 00000000..66912e07 --- /dev/null +++ b/lib/kamal/cli/proxy.rb @@ -0,0 +1,215 @@ +class Kamal::Cli::Proxy < Kamal::Cli::Base + desc "boot", "Boot proxy on servers" + def boot + 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.proxy_hosts) do |host| + execute *KAMAL.registry.login + + version = capture_with_info(*KAMAL.proxy.version).strip.presence + + if version && Kamal::Utils.older_version?(version, Kamal::Configuration::PROXY_MINIMUM_VERSION) + raise "kamal-proxy version #{version} is too old, please reboot to update to at least #{Kamal::Configuration::PROXY_MINIMUM_VERSION}" + end + execute *KAMAL.proxy.start_or_run + end + end + end + + desc "reboot", "Reboot 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 reboot + confirming "This will cause a brief outage on each host. Are you sure?" do + with_lock do + host_groups = options[:rolling] ? KAMAL.proxy_hosts : [ KAMAL.proxy_hosts ] + host_groups.each do |hosts| + host_list = Array(hosts).join(",") + run_hook "pre-proxy-reboot", hosts: host_list + on(hosts) do |host| + execute *KAMAL.auditor.record("Rebooted proxy"), verbosity: :debug + execute *KAMAL.registry.login + + "Stopping and removing Traefik on #{host}, if running..." + execute *KAMAL.proxy.cleanup_traefik + + "Stopping and removing kamal-proxy on #{host}, if running..." + execute *KAMAL.proxy.stop, raise_on_non_zero_exit: false + execute *KAMAL.proxy.remove_container + + execute *KAMAL.proxy.run + + KAMAL.roles_on(host).select(&:running_proxy?).each do |role| + app = KAMAL.app(role: role, host: host) + + version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip + endpoint = capture_with_info(*app.container_id_for_version(version)).strip + + if endpoint.present? + info "Deploying #{endpoint} for role `#{role}` on #{host}..." + execute *app.deploy(target: endpoint) + end + end + end + run_hook "post-proxy-reboot", hosts: host_list + end + end + end + end + + desc "upgrade", "Upgrade to kamal-proxy on servers (stop container, remove container, start new container, reboot app)", hide: true + 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.latest_tag }.merge(options) + + 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(",") + say "Upgrading proxy on #{host_list}...", :magenta + run_hook "pre-proxy-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.proxy.cleanup_traefik + + "Stopping and removing kamal-proxy on #{host}, if running..." + execute *KAMAL.proxy.stop, raise_on_non_zero_exit: false + execute *KAMAL.proxy.remove_container + execute *KAMAL.proxy.remove_image + end + + KAMAL.with_specific_hosts(hosts) do + invoke "kamal:cli:proxy:boot", [], invoke_options + reset_invocation(Kamal::Cli::Proxy) + invoke "kamal:cli:app:boot", [], invoke_options + reset_invocation(Kamal::Cli::App) + invoke "kamal:cli:prune:all", [], invoke_options + reset_invocation(Kamal::Cli::Prune) + end + + run_hook "post-proxy-reboot", hosts: host_list + say "Upgraded proxy on #{host_list}", :magenta + end + end + end + + desc "start", "Start existing proxy container on servers" + def start + with_lock do + on(KAMAL.proxy_hosts) do |host| + execute *KAMAL.auditor.record("Started proxy"), verbosity: :debug + execute *KAMAL.proxy.start + end + end + end + + desc "stop", "Stop existing proxy container on servers" + def stop + with_lock do + on(KAMAL.proxy_hosts) do |host| + execute *KAMAL.auditor.record("Stopped proxy"), verbosity: :debug + execute *KAMAL.proxy.stop, raise_on_non_zero_exit: false + end + end + end + + desc "restart", "Restart existing proxy container on servers" + def restart + with_lock do + stop + start + end + end + + desc "details", "Show details about proxy container from servers" + def details + on(KAMAL.proxy_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.proxy.info), type: "Proxy" } + end + + desc "logs", "Show log lines from proxy on servers" + option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)" + option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server" + option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" + option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)" + option :skip_timestamps, type: :boolean, aliases: "-T", desc: "Skip appending timestamps to logging output" + def logs + grep = options[:grep] + timestamps = !options[:skip_timestamps] + + if options[:follow] + run_locally do + info "Following logs on #{KAMAL.primary_host}..." + info KAMAL.proxy.follow_logs(host: KAMAL.primary_host, timestamps: timestamps, grep: grep) + exec KAMAL.proxy.follow_logs(host: KAMAL.primary_host, timestamps: timestamps, grep: grep) + end + else + since = options[:since] + lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set + + on(KAMAL.proxy_hosts) do |host| + puts_by_host host, capture(*KAMAL.proxy.logs(timestamps: timestamps, since: since, lines: lines, grep: grep)), type: "Proxy" + end + end + end + + desc "remove", "Remove proxy container and image from servers" + option :force, type: :boolean, default: false, desc: "Force removing proxy when apps are still installed" + def remove + with_lock do + if removal_allowed?(options[:force]) + stop + remove_container + remove_image + end + end + end + + desc "remove_container", "Remove proxy container from servers", hide: true + def remove_container + with_lock do + on(KAMAL.proxy_hosts) do + execute *KAMAL.auditor.record("Removed proxy container"), verbosity: :debug + execute *KAMAL.proxy.remove_container + end + end + end + + desc "remove_image", "Remove proxy image from servers", hide: true + def remove_image + with_lock do + on(KAMAL.proxy_hosts) do + execute *KAMAL.auditor.record("Removed proxy image"), verbosity: :debug + execute *KAMAL.proxy.remove_image + end + end + end + + private + def removal_allowed?(force) + on(KAMAL.proxy_hosts) do |host| + app_count = capture_with_info(*KAMAL.server.app_directory_count).chomp.to_i + raise "The are other applications installed on #{host}" if app_count > 0 + end + + true + rescue SSHKit::Runner::ExecuteError => e + raise unless e.message.include?("The are other applications installed on") + + if force + say "Forcing, so removing the proxy, even though other apps are installed", :magenta + else + say "Not removing the proxy, as other apps are installed, ignore this check with kamal proxy remove --force", :magenta + end + + force + end +end diff --git a/lib/kamal/cli/prune.rb b/lib/kamal/cli/prune.rb index 7635e97d..aaf79980 100644 --- a/lib/kamal/cli/prune.rb +++ b/lib/kamal/cli/prune.rb @@ -28,7 +28,6 @@ class Kamal::Cli::Prune < Kamal::Cli::Base on(KAMAL.hosts) do execute *KAMAL.auditor.record("Pruned containers"), verbosity: :debug execute *KAMAL.prune.app_containers(retain: retain) - execute *KAMAL.prune.healthcheck_containers end end end diff --git a/lib/kamal/cli/server.rb b/lib/kamal/cli/server.rb index 5b1b0cc7..452ab089 100644 --- a/lib/kamal/cli/server.rb +++ b/lib/kamal/cli/server.rb @@ -36,8 +36,6 @@ class Kamal::Cli::Server < Kamal::Cli::Base missing << host end end - - execute(*KAMAL.server.ensure_run_directory) end if missing.any? diff --git a/lib/kamal/cli/templates/deploy.yml b/lib/kamal/cli/templates/deploy.yml index 119961b9..8ecf5d04 100644 --- a/lib/kamal/cli/templates/deploy.yml +++ b/lib/kamal/cli/templates/deploy.yml @@ -57,17 +57,6 @@ builder: # directories: # - data:/data -# Configure custom arguments for Traefik. Be sure to reboot traefik when you modify it. -# traefik: -# args: -# accesslog: true -# accesslog.format: json - -# Configure a custom healthcheck (default is /up on port 3000) -# healthcheck: -# path: /healthz -# port: 4000 - # Bridge fingerprinted assets, like JS and CSS, between versions to avoid # hitting 404 on in-flight requests. Combines all files from new and old # version inside the asset_path. diff --git a/lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample b/lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample new file mode 100755 index 00000000..1435a677 --- /dev/null +++ b/lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/lib/kamal/cli/templates/sample_hooks/post-traefik-reboot.sample b/lib/kamal/cli/templates/sample_hooks/post-traefik-reboot.sample deleted file mode 100755 index e3d9e3cc..00000000 --- a/lib/kamal/cli/templates/sample_hooks/post-traefik-reboot.sample +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -echo "Rebooted Traefik on $KAMAL_HOSTS" diff --git a/lib/kamal/cli/templates/sample_hooks/pre-traefik-reboot.sample b/lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample similarity index 100% rename from lib/kamal/cli/templates/sample_hooks/pre-traefik-reboot.sample rename to lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample diff --git a/lib/kamal/cli/traefik.rb b/lib/kamal/cli/traefik.rb deleted file mode 100644 index 41ffbc04..00000000 --- a/lib/kamal/cli/traefik.rb +++ /dev/null @@ -1,124 +0,0 @@ -class Kamal::Cli::Traefik < Kamal::Cli::Base - desc "boot", "Boot Traefik on servers" - def boot - with_lock do - on(KAMAL.traefik_hosts) do - 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 - end - end - end - - desc "reboot", "Reboot Traefik on servers (stop container, remove container, start new container)" - option :rolling, type: :boolean, default: false, desc: "Reboot traefik on hosts in sequence, rather than in parallel" - option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" - def reboot - confirming "This will cause a brief outage on each host. Are you sure?" do - with_lock do - host_groups = options[:rolling] ? KAMAL.traefik_hosts : [ KAMAL.traefik_hosts ] - host_groups.each do |hosts| - host_list = Array(hosts).join(",") - run_hook "pre-traefik-reboot", hosts: host_list - on(hosts) do - execute *KAMAL.auditor.record("Rebooted traefik"), verbosity: :debug - execute *KAMAL.registry.login - execute *KAMAL.traefik.stop, raise_on_non_zero_exit: false - execute *KAMAL.traefik.remove_container - execute *KAMAL.traefik.run - end - run_hook "post-traefik-reboot", hosts: host_list - end - end - end - end - - desc "start", "Start existing Traefik container on servers" - def start - with_lock do - on(KAMAL.traefik_hosts) do - execute *KAMAL.auditor.record("Started traefik"), verbosity: :debug - execute *KAMAL.traefik.start - end - end - end - - desc "stop", "Stop existing Traefik container on servers" - def stop - with_lock do - on(KAMAL.traefik_hosts) do - execute *KAMAL.auditor.record("Stopped traefik"), verbosity: :debug - execute *KAMAL.traefik.stop, raise_on_non_zero_exit: false - end - end - end - - desc "restart", "Restart existing Traefik container on servers" - def restart - with_lock do - stop - start - end - end - - desc "details", "Show details about Traefik container from servers" - def details - on(KAMAL.traefik_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.traefik.info), type: "Traefik" } - end - - desc "logs", "Show log lines from Traefik on servers" - option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)" - option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server" - option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" - option :grep_options, aliases: "-o", desc: "Additional options supplied to grep" - option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)" - def logs - grep = options[:grep] - grep_options = options[:grep_options] - - if options[:follow] - run_locally do - info "Following logs on #{KAMAL.primary_host}..." - info KAMAL.traefik.follow_logs(host: KAMAL.primary_host, grep: grep, grep_options: grep_options) - exec KAMAL.traefik.follow_logs(host: KAMAL.primary_host, grep: grep, grep_options: grep_options) - end - else - since = options[:since] - lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set - - on(KAMAL.traefik_hosts) do |host| - puts_by_host host, capture(*KAMAL.traefik.logs(since: since, lines: lines, grep: grep, grep_options: grep_options)), type: "Traefik" - end - end - end - - desc "remove", "Remove Traefik container and image from servers" - def remove - with_lock do - stop - remove_container - remove_image - end - end - - desc "remove_container", "Remove Traefik container from servers", hide: true - def remove_container - with_lock do - on(KAMAL.traefik_hosts) do - execute *KAMAL.auditor.record("Removed traefik container"), verbosity: :debug - execute *KAMAL.traefik.remove_container - end - end - end - - desc "remove_image", "Remove Traefik image from servers", hide: true - def remove_image - with_lock do - on(KAMAL.traefik_hosts) do - execute *KAMAL.auditor.record("Removed traefik image"), verbosity: :debug - execute *KAMAL.traefik.remove_image - end - end - end -end diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index 11914a67..1557df57 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -4,7 +4,7 @@ require "active_support/core_ext/object/blank" class Kamal::Commander attr_accessor :verbosity, :holding_lock, :connected - delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :traefik_hosts, :accessory_hosts, to: :specifics + delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :proxy_hosts, :accessory_hosts, to: :specifics def initialize self.verbosity = :info @@ -65,6 +65,13 @@ class Kamal::Commander end end + def with_specific_hosts(hosts) + original_hosts, self.specific_hosts = specific_hosts, hosts + yield + ensure + self.specific_hosts = original_hosts + end + def accessory_names config.accessories&.collect(&:name) || [] end @@ -94,10 +101,6 @@ class Kamal::Commander @docker ||= Kamal::Commands::Docker.new(config) end - def healthcheck - @healthcheck ||= Kamal::Commands::Healthcheck.new(config) - end - def hook @hook ||= Kamal::Commands::Hook.new(config) end @@ -106,6 +109,10 @@ class Kamal::Commander @lock ||= Kamal::Commands::Lock.new(config) end + def proxy + @proxy ||= Kamal::Commands::Proxy.new(config) + end + def prune @prune ||= Kamal::Commands::Prune.new(config) end @@ -118,10 +125,6 @@ class Kamal::Commander @server ||= Kamal::Commands::Server.new(config) end - def traefik - @traefik ||= Kamal::Commands::Traefik.new(config) - end - def alias(name) config.aliases[name] end diff --git a/lib/kamal/commander/specifics.rb b/lib/kamal/commander/specifics.rb index 127bd40e..190d2b69 100644 --- a/lib/kamal/commander/specifics.rb +++ b/lib/kamal/commander/specifics.rb @@ -18,12 +18,12 @@ class Kamal::Commander::Specifics roles.select { |role| role.hosts.include?(host.to_s) } end - def traefik_hosts - config.traefik_hosts & specified_hosts + def proxy_hosts + config.proxy_hosts & specified_hosts end def accessory_hosts - specific_hosts || config.accessories.flat_map(&:hosts) + config.accessories.flat_map(&:hosts) & specified_hosts end private diff --git a/lib/kamal/commands/accessory.rb b/lib/kamal/commands/accessory.rb index f3b676d1..0c1b9009 100644 --- a/lib/kamal/commands/accessory.rb +++ b/lib/kamal/commands/accessory.rb @@ -15,6 +15,7 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base "--name", service_name, "--detach", "--restart", "unless-stopped", + "--network", "kamal", *config.logging_args, *publish_args, *env_args, @@ -38,16 +39,16 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base end - def logs(since: nil, lines: nil, grep: nil, grep_options: nil) + def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil) pipe \ - docker(:logs, service_name, (" --since #{since}" if since), (" --tail #{lines}" if lines), "--timestamps", "2>&1"), + docker(:logs, service_name, (" --since #{since}" if since), (" --tail #{lines}" if lines), ("--timestamps" if timestamps), "2>&1"), ("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep) end - def follow_logs(grep: nil, grep_options: nil) + def follow_logs(timestamps: true, grep: nil, grep_options: nil) run_over_ssh \ pipe \ - docker(:logs, service_name, "--timestamps", "--tail", "10", "--follow", "2>&1"), + docker(:logs, service_name, ("--timestamps" if timestamps), "--tail", "10", "--follow", "2>&1"), (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) end @@ -63,6 +64,7 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base docker :run, ("-it" if interactive), "--rm", + "--network", "kamal", *env_args, *volume_args, image, diff --git a/lib/kamal/commands/app.rb b/lib/kamal/commands/app.rb index f1991e48..6d8f44c6 100644 --- a/lib/kamal/commands/app.rb +++ b/lib/kamal/commands/app.rb @@ -1,10 +1,12 @@ class Kamal::Commands::App < Kamal::Commands::Base - include Assets, Containers, Cord, Execution, Images, Logging + include Assets, Containers, Execution, Images, Logging, Proxy ACTIVE_DOCKER_STATUSES = [ :running, :restarting ] attr_reader :role, :host + delegate :container_name, to: :role + def initialize(config, role: nil, host: nil) super(config) @role = role @@ -16,11 +18,11 @@ class Kamal::Commands::App < Kamal::Commands::Base "--detach", "--restart unless-stopped", "--name", container_name, + "--network", "kamal", *([ "--hostname", hostname ] if hostname), "-e", "KAMAL_CONTAINER_NAME=\"#{container_name}\"", "-e", "KAMAL_VERSION=\"#{config.version}\"", *role.env_args(host), - *role.health_check_args, *role.logging_args, *config.volume_args, *role.asset_volume_args, @@ -41,7 +43,7 @@ class Kamal::Commands::App < Kamal::Commands::Base def stop(version: nil) pipe \ version ? container_id_for_version(version) : current_running_container_id, - xargs(config.stop_wait_time ? docker(:stop, "-t", config.stop_wait_time) : docker(:stop)) + xargs(docker(:stop, *role.stop_args)) end def info @@ -74,10 +76,6 @@ class Kamal::Commands::App < Kamal::Commands::Base end private - def container_name(version = nil) - [ role.container_prefix, version || config.version ].compact.join("-") - end - def latest_image_id docker :image, :ls, *argumentize("--filter", "reference=#{config.latest_image}"), "--format", "'{{.ID}}'" end diff --git a/lib/kamal/commands/app/assets.rb b/lib/kamal/commands/app/assets.rb index 9841f4fb..c1e65d18 100644 --- a/lib/kamal/commands/app/assets.rb +++ b/lib/kamal/commands/app/assets.rb @@ -3,18 +3,18 @@ module Kamal::Commands::App::Assets asset_container = "#{role.container_prefix}-assets" combine \ - make_directory(role.asset_extracted_path), + make_directory(role.asset_extracted_directory), [ *docker(:stop, "-t 1", asset_container, "2> /dev/null"), "|| true" ], docker(:run, "--name", asset_container, "--detach", "--rm", "--entrypoint", "sleep", config.absolute_image, "1000000"), - docker(:cp, "-L", "#{asset_container}:#{role.asset_path}/.", role.asset_extracted_path), + docker(:cp, "-L", "#{asset_container}:#{role.asset_path}/.", role.asset_extracted_directory), docker(:stop, "-t 1", asset_container), by: "&&" end def sync_asset_volumes(old_version: nil) - new_extracted_path, new_volume_path = role.asset_extracted_path(config.version), role.asset_volume.host_path + new_extracted_path, new_volume_path = role.asset_extracted_directory(config.version), role.asset_volume.host_path if old_version.present? - old_extracted_path, old_volume_path = role.asset_extracted_path(old_version), role.asset_volume(old_version).host_path + old_extracted_path, old_volume_path = role.asset_extracted_directory(old_version), role.asset_volume(old_version).host_path end commands = [ make_directory(new_volume_path), copy_contents(new_extracted_path, new_volume_path) ] @@ -29,8 +29,8 @@ module Kamal::Commands::App::Assets def clean_up_assets chain \ - find_and_remove_older_siblings(role.asset_extracted_path), - find_and_remove_older_siblings(role.asset_volume_path) + find_and_remove_older_siblings(role.asset_extracted_directory), + find_and_remove_older_siblings(role.asset_volume_directory) end private @@ -39,7 +39,7 @@ module Kamal::Commands::App::Assets :find, Pathname.new(path).dirname.to_s, "-maxdepth 1", - "-name", "'#{role.container_prefix}-*'", + "-name", "'#{role.name}-*'", "!", "-name", Pathname.new(path).basename.to_s, "-exec rm -rf \"{}\" +" ] diff --git a/lib/kamal/commands/app/cord.rb b/lib/kamal/commands/app/cord.rb deleted file mode 100644 index 4912992e..00000000 --- a/lib/kamal/commands/app/cord.rb +++ /dev/null @@ -1,22 +0,0 @@ -module Kamal::Commands::App::Cord - def cord(version:) - pipe \ - docker(:inspect, "-f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}'", container_name(version)), - [ :awk, "'$2 == \"#{role.cord_volume.container_path}\" {print $1}'" ] - end - - def tie_cord(cord) - create_empty_file(cord) - end - - def cut_cord(cord) - remove_directory(cord) - end - - private - def create_empty_file(file) - chain \ - make_directory_for(file), - [ :touch, file ] - end -end diff --git a/lib/kamal/commands/app/execution.rb b/lib/kamal/commands/app/execution.rb index 215821dc..4434c26a 100644 --- a/lib/kamal/commands/app/execution.rb +++ b/lib/kamal/commands/app/execution.rb @@ -11,6 +11,7 @@ module Kamal::Commands::App::Execution docker :run, ("-it" if interactive), "--rm", + "--network", "kamal", *role&.env_args(host), *argumentize("--env", env), *config.volume_args, diff --git a/lib/kamal/commands/app/logging.rb b/lib/kamal/commands/app/logging.rb index be8a4bad..ad66f370 100644 --- a/lib/kamal/commands/app/logging.rb +++ b/lib/kamal/commands/app/logging.rb @@ -1,16 +1,16 @@ module Kamal::Commands::App::Logging - def logs(version: nil, since: nil, lines: nil, grep: nil, grep_options: nil) + def logs(version: nil, timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil) pipe \ version ? container_id_for_version(version) : current_running_container_id, - "xargs docker logs#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1", + "xargs docker logs#{" --timestamps" if timestamps}#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1", ("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep) end - def follow_logs(host:, lines: nil, grep: nil, grep_options: nil) + def follow_logs(host:, timestamps: true, lines: nil, grep: nil, grep_options: nil) run_over_ssh \ pipe( current_running_container_id, - "xargs docker logs --timestamps#{" --tail #{lines}" if lines} --follow 2>&1", + "xargs docker logs#{" --timestamps" if timestamps}#{" --tail #{lines}" if lines} --follow 2>&1", (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) ), host: host diff --git a/lib/kamal/commands/app/proxy.rb b/lib/kamal/commands/app/proxy.rb new file mode 100644 index 00000000..550e2e3b --- /dev/null +++ b/lib/kamal/commands/app/proxy.rb @@ -0,0 +1,16 @@ +module Kamal::Commands::App::Proxy + delegate :proxy_container_name, to: :config + + def deploy(target:) + proxy_exec :deploy, role.container_prefix, *role.proxy.deploy_command_args(target: target) + end + + def remove(target:) + proxy_exec :remove, role.container_prefix, *role.proxy.remove_command_args(target: target) + end + + private + def proxy_exec(*command) + docker :exec, proxy_container_name, "kamal-proxy", *command + end +end diff --git a/lib/kamal/commands/docker.rb b/lib/kamal/commands/docker.rb index 2966e095..bc9345df 100644 --- a/lib/kamal/commands/docker.rb +++ b/lib/kamal/commands/docker.rb @@ -19,6 +19,10 @@ class Kamal::Commands::Docker < Kamal::Commands::Base [ '[ "${EUID:-$(id -u)}" -eq 0 ] || command -v sudo >/dev/null || command -v su >/dev/null' ] end + def create_network + docker :network, :create, :kamal + end + private def get_docker shell \ diff --git a/lib/kamal/commands/hook.rb b/lib/kamal/commands/hook.rb index eb710d5e..cb622d3b 100644 --- a/lib/kamal/commands/hook.rb +++ b/lib/kamal/commands/hook.rb @@ -1,9 +1,12 @@ class Kamal::Commands::Hook < Kamal::Commands::Base - def run(hook, secrets: false, **details) - env = tags(**details).env - env.merge!(config.secrets.to_h) if secrets + def run(hook) + [ hook_file(hook) ] + end - [ hook_file(hook), env: env ] + def env(secrets: false, **details) + tags(**details).env.tap do |env| + env.merge!(config.secrets.to_h) if secrets + end end def hook_exists?(hook) diff --git a/lib/kamal/commands/lock.rb b/lib/kamal/commands/lock.rb index 395b6f3a..aafaec70 100644 --- a/lib/kamal/commands/lock.rb +++ b/lib/kamal/commands/lock.rb @@ -44,14 +44,10 @@ class Kamal::Commands::Lock < Kamal::Commands::Base "/dev/null" end - def locks_dir - File.join(config.run_directory, "locks") - end - def lock_dir - dir_name = [ config.service, config.destination ].compact.join("-") + dir_name = [ "lock", config.service, config.destination ].compact.join("-") - File.join(locks_dir, dir_name) + File.join(config.run_directory, dir_name) end def lock_details_file diff --git a/lib/kamal/commands/proxy.rb b/lib/kamal/commands/proxy.rb new file mode 100644 index 00000000..5ac1c94e --- /dev/null +++ b/lib/kamal/commands/proxy.rb @@ -0,0 +1,72 @@ +class Kamal::Commands::Proxy < Kamal::Commands::Base + delegate :argumentize, :optionize, to: Kamal::Utils + + def run + docker :run, + "--name", container_name, + "--network", "kamal", + "--detach", + "--restart", "unless-stopped", + *config.proxy_publish_args, + "--volume", "kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", + *config.logging_args, + config.proxy_image + end + + def start + docker :container, :start, container_name + end + + def stop(name: container_name) + docker :container, :stop, name + end + + def start_or_run + combine start, run, by: "||" + end + + def info + docker :ps, "--filter", "name=^#{container_name}$" + end + + def version + pipe \ + docker(:inspect, container_name, "--format '{{.Config.Image}}'"), + [ :cut, "-d:", "-f2" ] + end + + def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil) + pipe \ + docker(:logs, container_name, ("--since #{since}" if since), ("--tail #{lines}" if lines), ("--timestamps" if timestamps), "2>&1"), + ("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep) + end + + def follow_logs(host:, timestamps: true, grep: nil, grep_options: nil) + run_over_ssh pipe( + docker(:logs, container_name, ("--timestamps" if timestamps), "--tail", "10", "--follow", "2>&1"), + (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) + ).join(" "), host: host + end + + def remove_container + docker :container, :prune, "--force", "--filter", "label=org.opencontainers.image.title=kamal-proxy" + end + + def remove_image + docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=kamal-proxy" + end + + def cleanup_traefik + chain \ + docker(:container, :stop, "traefik"), + combine( + docker(:container, :prune, "--force", "--filter", "label=org.opencontainers.image.title=Traefik"), + docker(:image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik") + ) + end + + private + def container_name + config.proxy_container_name + end +end diff --git a/lib/kamal/commands/prune.rb b/lib/kamal/commands/prune.rb index b820b5af..30b6eafd 100644 --- a/lib/kamal/commands/prune.rb +++ b/lib/kamal/commands/prune.rb @@ -20,10 +20,6 @@ class Kamal::Commands::Prune < Kamal::Commands::Base "while read container_id; do docker rm $container_id; done" end - def healthcheck_containers - docker :container, :prune, "--force", *healthcheck_service_filter - end - private def stopped_containers_filters [ "created", "exited", "dead" ].flat_map { |status| [ "--filter", "status=#{status}" ] } @@ -39,8 +35,4 @@ class Kamal::Commands::Prune < Kamal::Commands::Base def service_filter [ "--filter", "label=service=#{config.service}" ] end - - def healthcheck_service_filter - [ "--filter", "label=service=#{config.healthcheck_service}" ] - end end diff --git a/lib/kamal/commands/server.rb b/lib/kamal/commands/server.rb index fb781fe8..305903f6 100644 --- a/lib/kamal/commands/server.rb +++ b/lib/kamal/commands/server.rb @@ -1,5 +1,15 @@ class Kamal::Commands::Server < Kamal::Commands::Base def ensure_run_directory - [ :mkdir, "-p", config.run_directory ] + make_directory config.run_directory + end + + def remove_app_directory + remove_directory config.app_directory + end + + def app_directory_count + pipe \ + [ :ls, config.apps_directory ], + [ :wc, "-l" ] end end diff --git a/lib/kamal/commands/traefik.rb b/lib/kamal/commands/traefik.rb deleted file mode 100644 index 964ef3eb..00000000 --- a/lib/kamal/commands/traefik.rb +++ /dev/null @@ -1,77 +0,0 @@ -class Kamal::Commands::Traefik < Kamal::Commands::Base - 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" - - def run - docker :run, "--name traefik", - "--detach", - "--restart", "unless-stopped", - *publish_args, - "--volume", "/var/run/docker.sock:/var/run/docker.sock", - *env_args, - *config.logging_args, - *label_args, - *docker_options_args, - image, - "--providers.docker", - *cmd_option_args - end - - def start - docker :container, :start, "traefik" - end - - def stop - docker :container, :stop, "traefik" - end - - def start_or_run - any start, run - end - - def info - docker :ps, "--filter", "name=^traefik$" - end - - def logs(since: nil, lines: nil, grep: nil, grep_options: nil) - pipe \ - docker(:logs, "traefik", (" --since #{since}" if since), (" --tail #{lines}" if lines), "--timestamps", "2>&1"), - ("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep) - end - - def follow_logs(host:, grep: nil, grep_options: nil) - run_over_ssh pipe( - docker(:logs, "traefik", "--timestamps", "--tail", "10", "--follow", "2>&1"), - (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) - ).join(" "), host: host - end - - def remove_container - docker :container, :prune, "--force", "--filter", "label=org.opencontainers.image.title=Traefik" - end - - def remove_image - docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik" - end - - def ensure_env_directory - make_directory env_directory - end - - private - def publish_args - argumentize "--publish", port if publish? - end - - def label_args - argumentize "--label", labels - end - - def docker_options_args - optionize(options) - end - - def cmd_option_args - optionize args, with: "=" - end -end diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 2758d15a..2848a365 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -6,14 +6,18 @@ require "erb" require "net/ssh/proxy/jump" class Kamal::Configuration - delegate :service, :image, :labels, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true + delegate :service, :image, :labels, :hooks_path, to: :raw_config, allow_nil: true delegate :argumentize, :optionize, to: Kamal::Utils attr_reader :destination, :raw_config, :secrets - attr_reader :accessories, :aliases, :boot, :builder, :env, :healthcheck, :logging, :traefik, :servers, :ssh, :sshkit, :registry + attr_reader :accessories, :aliases, :boot, :builder, :env, :logging, :proxy, :servers, :ssh, :sshkit, :registry include Validation + PROXY_MINIMUM_VERSION = "v0.4.0" + PROXY_HTTP_PORT = 80 + PROXY_HTTPS_PORT = 443 + class << self def create_from(config_file:, destination: nil, version: nil) raw_config = load_config_files(config_file, *destination_config_file(config_file, destination)) @@ -48,6 +52,8 @@ class Kamal::Configuration validate! raw_config, example: validation_yml.symbolize_keys, context: "", with: Kamal::Configuration::Validator::Configuration + @secrets = Kamal::Secrets.new(destination: destination) + # Eager load config to validate it, these are first as they have dependencies later on @servers = Servers.new(config: self) @registry = Registry.new(config: self) @@ -58,19 +64,19 @@ class Kamal::Configuration @builder = Builder.new(config: self) @env = Env.new(config: @raw_config.env || {}, secrets: secrets) - @healthcheck = Healthcheck.new(healthcheck_config: @raw_config.healthcheck) @logging = Logging.new(logging_config: @raw_config.logging) - @traefik = Traefik.new(config: self) + @proxy = Proxy.new(config: self, proxy_config: @raw_config.proxy || {}) @ssh = Ssh.new(config: self) @sshkit = Sshkit.new(config: self) - @secrets = Kamal::Secrets.new(destination: destination) - ensure_destination_if_required ensure_required_keys_present ensure_valid_kamal_version ensure_retain_containers_valid ensure_valid_service_name + ensure_no_traefik_reboot_hooks + ensure_one_host_for_ssl_roles + ensure_unique_hosts_for_ssl_roles end @@ -131,16 +137,16 @@ class Kamal::Configuration raw_config.allow_empty_roles end - def traefik_roles - roles.select(&:running_traefik?) + def proxy_roles + roles.select(&:running_proxy?) end - def traefik_role_names - traefik_roles.flat_map(&:name) + def proxy_role_names + proxy_roles.flat_map(&:name) end - def traefik_hosts - traefik_roles.flat_map(&:hosts).uniq + def proxy_hosts + proxy_roles.flat_map(&:hosts).uniq end def repository @@ -185,31 +191,40 @@ class Kamal::Configuration end - def healthcheck_service - [ "healthcheck", service, destination ].compact.join("-") - end - def readiness_delay raw_config.readiness_delay || 7 end - def run_id - @run_id ||= SecureRandom.hex(16) + def deploy_timeout + raw_config.deploy_timeout || 30 + end + + def drain_timeout + raw_config.drain_timeout || 30 end def run_directory - raw_config.run_directory || ".kamal" + ".kamal" end - def run_directory_as_docker_volume - if Pathname.new(run_directory).absolute? - run_directory - else - File.join "$(pwd)", run_directory - end + def apps_directory + File.join run_directory, "apps" end + def app_directory + File.join apps_directory, [ service, destination ].compact.join("-") + end + + def env_directory + File.join app_directory, "env" + end + + def assets_directory + File.join app_directory, "assets" + end + + def hooks_path raw_config.hooks_path || ".kamal/hooks" end @@ -219,10 +234,6 @@ class Kamal::Configuration end - def env_directory - File.join(run_directory, "env") - end - def env_tags @env_tags ||= if (tags = raw_config.env["tags"]) tags.collect { |name, config| Env::Tag.new(name, config: config, secrets: secrets) } @@ -235,6 +246,18 @@ class Kamal::Configuration env_tags.detect { |t| t.name == name.to_s } end + def proxy_publish_args + argumentize "--publish", [ "#{PROXY_HTTP_PORT}:#{PROXY_HTTP_PORT}", "#{PROXY_HTTPS_PORT}:#{PROXY_HTTPS_PORT}" ] + end + + def proxy_image + "basecamp/kamal-proxy:#{PROXY_MINIMUM_VERSION}" + end + + def proxy_container_name + "kamal-proxy" + end + def to_h { @@ -250,15 +273,10 @@ class Kamal::Configuration sshkit: sshkit.to_h, builder: builder.to_h, accessories: raw_config.accessories, - logging: logging_args, - healthcheck: healthcheck.to_h + logging: logging_args }.compact end - def secrets - @secrets ||= Kamal::Secrets.new(destination: destination) - end - private # Will raise ArgumentError if any required config keys are missing def ensure_destination_if_required @@ -313,6 +331,30 @@ class Kamal::Configuration true end + def ensure_no_traefik_reboot_hooks + hooks = %w[ pre-traefik-reboot post-traefik-reboot ].select { |hook_file| File.exist?(File.join(hooks_path, hook_file)) } + + if hooks.any? + raise Kamal::ConfigurationError, "Found #{hooks.join(", ")}, these should be renamed to (pre|post)-proxy-reboot" + end + + true + end + + def ensure_one_host_for_ssl_roles + roles.each(&:ensure_one_host_for_ssl) + + true + end + + def ensure_unique_hosts_for_ssl_roles + hosts = roles.select(&:ssl?).map { |role| role.proxy.host } + duplicates = hosts.tally.filter_map { |host, count| host if count > 1 } + + raise Kamal::ConfigurationError, "Different roles can't share the same host for SSL: #{duplicates.join(", ")}" if duplicates.any? + + true + end def role_names raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort diff --git a/lib/kamal/configuration/accessory.rb b/lib/kamal/configuration/accessory.rb index 57489f17..804a1502 100644 --- a/lib/kamal/configuration/accessory.rb +++ b/lib/kamal/configuration/accessory.rb @@ -63,7 +63,7 @@ class Kamal::Configuration::Accessory end def secrets_path - File.join(config.env_directory, "accessories", "#{service_name}.env") + File.join(config.env_directory, "accessories", "#{name}.env") end def files diff --git a/lib/kamal/configuration/docs/builder.yml b/lib/kamal/configuration/docs/builder.yml index 26c22867..c2d98548 100644 --- a/lib/kamal/configuration/docs/builder.yml +++ b/lib/kamal/configuration/docs/builder.yml @@ -2,35 +2,31 @@ # # The builder configuration controls how the application is built with `docker build` # -# If no configuration is specified, Kamal will: -# 1. Create a buildx context called `kamal-local-docker-container`, using the docker-container driver -# 2. Use `docker 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 # Builder options # # Options go under the builder key in the root configuration. builder: - # Driver - # - # The build driver to use, defaults to `docker-container` - driver: docker # Arch # - # The architectures to build for, defaults to `[ amd64, arm64 ]` - # Unless you are using the docker driver, when it defaults to the local architecture - # You can set an array or just a single value + # The architectures to build for - you can set an array or just a single value. + # + # Allowed values are `amd64` and `arm64` arch: - amd64 - # Remote configuration + # Remote # - # If you have a remote builder, you can configure it here + # The connection string for a remote builder. If supplied Kamal will use this + # for builds that do not match the local architecture of the deployment host. remote: ssh://docker@docker-builder - # Whether to allow local builds + # Local + # + # If set to false, Kamal will always use the remote builder even when building + # the local architecture. # # Defaults to true local: true @@ -87,7 +83,7 @@ builder: # Build secrets # - # Values are read from the environment. + # Values are read from .kamal/secrets. # secrets: - SECRET1 @@ -112,3 +108,8 @@ builder: # # SSH agent socket or keys to expose to the build ssh: default=$SSH_AUTH_SOCK + + # Driver + # + # The build driver to use, defaults to `docker-container` + driver: docker diff --git a/lib/kamal/configuration/docs/configuration.yml b/lib/kamal/configuration/docs/configuration.yml index f1045dd6..20530a3d 100644 --- a/lib/kamal/configuration/docs/configuration.yml +++ b/lib/kamal/configuration/docs/configuration.yml @@ -36,6 +36,8 @@ image: my-image labels: my-label: my-value +# Volumes +# # Additional volumes to mount into the container volumes: - /path/on/host:/path/in/container:ro @@ -58,7 +60,7 @@ servers: env: ... -# Asset Bridging +# Asset Path # # Used for asset bridging across deployments, default to `nil` # @@ -70,10 +72,12 @@ env: # volume containing both sets of files. # This requires that file names change when the contents change # (e.g. by including a hash of the contents in the name). - +# # To configure this, set the path to the assets: asset_path: /path/to/assets +# Hooks path +# # Path to hooks, defaults to `.kamal/hooks` # See https://kamal-deploy.org/docs/hooks for more information hooks_path: /user_home/kamal/hooks @@ -83,7 +87,7 @@ hooks_path: /user_home/kamal/hooks # Whether deployments require a destination to be specified, defaults to `false` require_destination: true -# The primary role +# Primary role # # This defaults to `web`, but if you have no web role, you can change this primary_role: workers @@ -93,11 +97,6 @@ primary_role: workers # Whether roles with no servers are allowed. Defaults to `false`. allow_empty_roles: false -# Stop wait time -# -# How long we wait for a container to stop before killing it, defaults to 30 seconds -stop_wait_time: 60 - # Retain containers # # How many old containers and images we retain, defaults to 5 @@ -111,9 +110,20 @@ minimum_version: 1.3.0 # Readiness delay # # Seconds to wait for a container to boot after is running, default 7 -# This only applies to containers that do not specify a healthcheck +# +# This only applies to containers that do not run a proxy or specify a healthcheck readiness_delay: 4 +# Deploy timeout +# +# How long to wait for a container to become ready, default 30 +deploy_timeout: 10 + +# Drain timeout +# +# How long to wait for a containers to drain, default 30 +drain_timeout: 10 + # Run directory # # Directory to store kamal runtime files in on the host, default `.kamal` @@ -137,10 +147,10 @@ builder: accessories: ... -# Traefik +# Proxy # -# The Traefik proxy is used for zero-downtime deployments, see kamal docs traefik -traefik: +# Configuration for kamal-proxy, see kamal docs proxy +proxy: ... # SSHKit @@ -155,12 +165,6 @@ sshkit: boot: ... -# Healthcheck -# -# Configuring healthcheck commands, intervals and timeouts, see kamal docs healthcheck -healthcheck: - ... - # Logging # # Docker logging configuration, see kamal docs logging diff --git a/lib/kamal/configuration/docs/env.yml b/lib/kamal/configuration/docs/env.yml index 0ca2bfa9..4c38442c 100644 --- a/lib/kamal/configuration/docs/env.yml +++ b/lib/kamal/configuration/docs/env.yml @@ -1,7 +1,7 @@ # Environment variables # # Environment variables can be set directly in the Kamal configuration or -# loaded from a .env file, for secrets that should not be checked into Git. +# read from .kamal/secrets. # Reading environment variables from the configuration # @@ -12,26 +12,38 @@ env: DATABASE_HOST: mysql-db1 DATABASE_PORT: 3306 -# Using .env file to load required environment variables +# Secrets # -# Kamal uses dotenv to automatically load environment variables set in the .env file present -# in the application root. +# Kamal uses dotenv to automatically load environment variables set in the `.kamal/secrets` file. +# +# If you are using destinations, secrets will instead be read from `.kamal/secrets-` if +# it exists. +# +# Common secrets across all destinations can be set in `.kamal/secrets-common`. +# +# This file can be used to set variables like `KAMAL_REGISTRY_PASSWORD` or database passwords. +# You can use variable or command substitution in the secrets file. # -# This file can be used to set variables like KAMAL_REGISTRY_PASSWORD or database passwords. -# But for this reason you must ensure that .env files are not checked into Git or included -# in your Dockerfile! The format is just key-value like: # ``` -# KAMAL_REGISTRY_PASSWORD=pw -# DB_PASSWORD=secret123 +# KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD +# RAILS_MASTER_KEY=$(cat config/master.key) # ``` -# See https://kamal-deploy.org/docs/commands/envify/ for how to use generated .env files. +# +# You can also use [secret helpers](../commands/secrets) for some common password managers. +# ``` +# SECRETS=$(kamal secrets fetch ...) +# +# REGISTRY_PASSWORD=$(kamal secrets extract REGISTRY_PASSWORD $SECRETS) +# DB_PASSWORD=$(kamal secrets extract DB_PASSWORD $SECRETS) +# ``` +# +# If you store secrets directly in .kamal/secrets, ensure that it is not checked into version control. # # 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. # # Unlike clear values, secrets are not passed directly to the container, # 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: clear: DB_USER: app diff --git a/lib/kamal/configuration/docs/healthcheck.yml b/lib/kamal/configuration/docs/healthcheck.yml deleted file mode 100644 index 3c55d5dd..00000000 --- a/lib/kamal/configuration/docs/healthcheck.yml +++ /dev/null @@ -1,59 +0,0 @@ -# Healthcheck configuration -# -# On roles that are running Traefik, Kamal will supply a default healthcheck to `docker run`. -# 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 -# to reach a running state and then pause for the readiness delay. -# -# The default healthcheck is `curl -f http://localhost:/`, so it assumes that `curl` -# is available within the container. - -# Healthcheck options -# -# These go under the `healthcheck` key in the root or role configuration. -healthcheck: - - # Command - # - # The command to run, defaults to `curl -f http://localhost:/` on roles running Traefik - cmd: "curl -f http://localhost" - - # Interval - # - # The Docker healthcheck interval, defaults to `1s` - interval: 10s - - # Max attempts - # - # The maximum number of times we poll the container to see if it is healthy, defaults to `7` - # Each check is separated by an increasing interval starting with 1 second. - max_attempts: 3 - - # Port - # - # The port to use in the healthcheck, defaults to `3000` - port: "80" - - # Path - # - # The path to use in the healthcheck, defaults to `/up` - path: /health - - # Cords for zero-downtime deployments - # - # The cord file is used for zero-downtime deployments. The healthcheck is augmented with a check - # for the existance of the file. This allows us to delete the file and force the container to - # become unhealthy, causing Traefik to stop routing traffic to it. - # - # Kamal mounts a volume at this location and creates the file before starting the container. - # You can set the value to `false` to disable the cord file, but this loses the zero-downtime - # guarantee. - # - # The default value is `/tmp/kamal-cord` - cord: /cord - - # Log lines - # - # Number of lines to log from the container when the healthcheck fails, defaults to `50` - log_lines: 100 diff --git a/lib/kamal/configuration/docs/proxy.yml b/lib/kamal/configuration/docs/proxy.yml new file mode 100644 index 00000000..04eea4fc --- /dev/null +++ b/lib/kamal/configuration/docs/proxy.yml @@ -0,0 +1,100 @@ +# Proxy +# +# Kamal uses [kamal-proxy](https://github.com/basecamp/kamal-proxy) to provide +# gapless deployments. It runs on ports 80 and 443 and forwards requests to the +# application container. +# +# The proxy is configured in the root configuration under `proxy`. These are +# options that are set when deploying the application, not when booting the proxy +# +# They are application specific, so are not shared when multiple applications +# run on the same proxy. +# +# The proxy is enabled by default on the primary role, but can be disabled by +# setting `proxy: false`. +# +# It is disabled by default on all other roles, but can be enabled by setting +# `proxy: true`, or providing a proxy configuration. +proxy: + + # Host + # + # The hosts that will be used to serve the app. The proxy will only route requests + # to this host to your app. + # + # If no hosts are set, then all requests will be forwarded, except for matching + # requests for other apps deployed on that server that do have a host set. + host: foo.example.com + + # App port + # + # The port the application container is exposed on + # + # Defaults to 80 + app_port: 3000 + + # SSL + # + # kamal-proxy can provide automatic HTTPS for your application via Let's Encrypt. + # + # This requires that we are deploying to a one server and the host option is set. + # The host value must point to the server we are deploying to and port 443 must be + # open for the Let's Encrypt challenge to succeed. + # + # Defaults to false + ssl: true + + # Response timeout + # + # How long to wait for requests to complete before timing out, defaults to 30 seconds + response_timeout: 10 + + # Healthcheck + # + # When deploying, the proxy will by default hit /up once every second until we hit + # the deploy timeout, with a 5 second timeout for each request. + # + # Once the app is up, the proxy will stop hitting the healthcheck endpoint. + healthcheck: + interval: 3 + path: /health + timeout: 3 + + # Buffering + # + # Whether to buffer request and response bodies in the proxy + # + # By default buffering is enabled with a max request body size of 1GB and no limit + # for response size. + # + # You can also set the memory limit for buffering, which defaults to 1MB, anything + # larger than that is written to disk. + buffering: + requests: true + responses: true + max_request_body: 40_000_000 + max_response_body: 0 + memory: 2_000_000 + + # Logging + # + # Configure request logging for the proxy + # You can specify request and response headers to log. + # By default, Cache-Control, Last-Modified and User-Agent request headers are logged + logging: + request_headers: + - Cache-Control + - X-Forwarded-Proto + response_headers: + - X-Request-ID + - X-Request-Start + + # Forward headers + # + # Whether to forward the X-Forwarded-For and X-Forwarded-Proto headers. + # + # If you are behind a trusted proxy, you can set this to true to forward the headers. + # + # By default kamal-proxy will not forward the headers the ssl option is set to true, and + # will forward them if it is set to false. + forward_headers: true diff --git a/lib/kamal/configuration/docs/registry.yml b/lib/kamal/configuration/docs/registry.yml index 3254e454..3fea9ad6 100644 --- a/lib/kamal/configuration/docs/registry.yml +++ b/lib/kamal/configuration/docs/registry.yml @@ -27,11 +27,13 @@ registry: # and [set up roles and permissions](https://cloud.google.com/artifact-registry/docs/access-control#permissions). # Normally, assigning a roles/artifactregistry.writer role should be sufficient. # -# Once the service account is ready, you need to generate and download a JSON key, base64 encode it and add to .env: +# Once the service account is ready, you need to generate and download a JSON key and base64 encode it: # # ```shell -# echo "KAMAL_REGISTRY_PASSWORD=$(base64 -i /path/to/key.json)" | tr -d "\\n" >> .env +# base64 -i /path/to/key.json | tr -d "\\n") # ``` +# You'll then need to set the KAMAL_REGISTRY_PASSWORD secret to that value. +# # Use the env variable as password along with _json_key_base64 as username. # Here’s the final configuration: diff --git a/lib/kamal/configuration/docs/role.yml b/lib/kamal/configuration/docs/role.yml index 9f6962a5..dc27c69e 100644 --- a/lib/kamal/configuration/docs/role.yml +++ b/lib/kamal/configuration/docs/role.yml @@ -26,8 +26,12 @@ servers: # # When there are other options to set, the list of hosts goes under the `hosts` key # - # By default only the primary role uses Traefik, but you can set `traefik` to change - # it. + # By default only the primary role uses a proxy. + # + # For other roles, you can set it to `proxy: true` enable it and inherit the root proxy + # configuration or provide a map of options to override the root configuration. + # + # For the primary role, you can set `proxy: false` to disable the proxy. # # You can also set a custom cmd to run in the container, and overwrite other settings # from the root configuration. @@ -35,18 +39,16 @@ servers: hosts: - 172.1.0.3 - 172.1.0.4: experiment1 - traefik: true cmd: "bin/jobs" options: memory: 2g cpus: 4 - healthcheck: - ... logging: ... + proxy: + ... labels: my-label: workers env: ... asset_path: /public - diff --git a/lib/kamal/configuration/docs/traefik.yml b/lib/kamal/configuration/docs/traefik.yml deleted file mode 100644 index 756afa9e..00000000 --- a/lib/kamal/configuration/docs/traefik.yml +++ /dev/null @@ -1,62 +0,0 @@ -# Traefik -# -# Traefik is a reverse proxy, used by Kamal for zero-downtime deployments. -# -# We start an instance on the hosts in it's own container. -# -# During a deployment: -# 1. We start a new container which Traefik automatically detects due to the labels we have applied -# 2. Traefik starts routing traffic to the new container -# 3. We force the old container to fail it's healthcheck, causing Traefik to stop routing traffic to it -# 4. We stop the old container - -# Traefik settings -# -# Traekik is configured in the root configuration under `traefik`. -traefik: - - # Image - # - # The Traefik image to use, defaults to `traefik:v2.10` - image: traefik:v2.9 - - # Host port - # - # The host port to publish the Traefik container on, defaults to `80` - host_port: "8080" - - # Disabling publishing - # - # To avoid publishing the Traefik container, set this to `false` - publish: false - - # Labels - # - # Additional labels to apply to the Traefik container - labels: - traefik.http.routers.catchall.entryPoints: http - traefik.http.routers.catchall.rule: PathPrefix(`/`) - traefik.http.routers.catchall.service: unavailable - traefik.http.routers.catchall.priority: "1" - traefik.http.services.unavailable.loadbalancer.server.port: "0" - - # Arguments - # - # Additional arguments to pass to the Traefik container - args: - entryPoints.http.address: ":80" - entryPoints.http.forwardedHeaders.insecure: true - accesslog: true - accesslog.format: json - - # Options - # - # Additional options to pass to `docker run` - options: - cpus: 2 - - # Environment variables - # - # See kamal docs env - env: - ... diff --git a/lib/kamal/configuration/healthcheck.rb b/lib/kamal/configuration/healthcheck.rb deleted file mode 100644 index 888068a4..00000000 --- a/lib/kamal/configuration/healthcheck.rb +++ /dev/null @@ -1,63 +0,0 @@ -class Kamal::Configuration::Healthcheck - include Kamal::Configuration::Validation - - attr_reader :healthcheck_config - - def initialize(healthcheck_config:, context: "healthcheck") - @healthcheck_config = healthcheck_config || {} - validate! @healthcheck_config, context: context - end - - def merge(other) - self.class.new healthcheck_config: healthcheck_config.deep_merge(other.healthcheck_config) - end - - def cmd - healthcheck_config.fetch("cmd", http_health_check) - end - - def port - healthcheck_config.fetch("port", 3000) - end - - def path - healthcheck_config.fetch("path", "/up") - end - - def max_attempts - healthcheck_config.fetch("max_attempts", 7) - end - - def interval - healthcheck_config.fetch("interval", "1s") - end - - def cord - healthcheck_config.fetch("cord", "/tmp/kamal-cord") - end - - def log_lines - healthcheck_config.fetch("log_lines", 50) - end - - def set_port_or_path? - healthcheck_config["port"].present? || healthcheck_config["path"].present? - end - - def to_h - { - "cmd" => cmd, - "interval" => interval, - "max_attempts" => max_attempts, - "port" => port, - "path" => path, - "cord" => cord, - "log_lines" => log_lines - } - end - - private - def http_health_check - "curl -f #{URI.join("http://localhost:#{port}", path)} || exit 1" if path.present? || port.present? - end -end diff --git a/lib/kamal/configuration/proxy.rb b/lib/kamal/configuration/proxy.rb new file mode 100644 index 00000000..af09e2c7 --- /dev/null +++ b/lib/kamal/configuration/proxy.rb @@ -0,0 +1,66 @@ +class Kamal::Configuration::Proxy + include Kamal::Configuration::Validation + + DEFAULT_LOG_REQUEST_HEADERS = [ "Cache-Control", "Last-Modified", "User-Agent" ] + CONTAINER_NAME = "kamal-proxy" + + delegate :argumentize, :optionize, to: Kamal::Utils + + attr_reader :config, :proxy_config + + def initialize(config:, proxy_config:, context: "proxy") + @config = config + @proxy_config = proxy_config + validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context + end + + def app_port + proxy_config.fetch("app_port", 80) + end + + def ssl? + proxy_config.fetch("ssl", false) + end + + def host + proxy_config["host"] + end + + def deploy_options + { + host: proxy_config["host"], + tls: proxy_config["ssl"], + "deploy-timeout": seconds_duration(config.deploy_timeout), + "drain-timeout": seconds_duration(config.drain_timeout), + "health-check-interval": seconds_duration(proxy_config.dig("healthcheck", "interval")), + "health-check-timeout": seconds_duration(proxy_config.dig("healthcheck", "timeout")), + "health-check-path": proxy_config.dig("healthcheck", "path"), + "target-timeout": seconds_duration(proxy_config["response_timeout"]), + "buffer-requests": proxy_config.fetch("buffering", { "requests": true }).fetch("requests", true), + "buffer-responses": proxy_config.fetch("buffering", { "responses": true }).fetch("responses", true), + "buffer-memory": proxy_config.dig("buffering", "memory"), + "max-request-body": proxy_config.dig("buffering", "max_request_body"), + "max-response-body": proxy_config.dig("buffering", "max_response_body"), + "forward-headers": proxy_config.dig("forward_headers"), + "log-request-header": proxy_config.dig("logging", "request_headers") || DEFAULT_LOG_REQUEST_HEADERS, + "log-response-header": proxy_config.dig("logging", "response_headers") + }.compact + end + + def deploy_command_args(target:) + optionize ({ target: "#{target}:#{app_port}" }).merge(deploy_options) + end + + def remove_command_args(target:) + optionize({ target: "#{target}:#{app_port}" }) + end + + def merge(other) + self.class.new config: config, proxy_config: proxy_config.deep_merge(other.proxy_config) + end + + private + def seconds_duration(value) + value ? "#{value}s" : nil + end +end diff --git a/lib/kamal/configuration/role.rb b/lib/kamal/configuration/role.rb index ef651898..708e77fc 100644 --- a/lib/kamal/configuration/role.rb +++ b/lib/kamal/configuration/role.rb @@ -1,10 +1,9 @@ class Kamal::Configuration::Role include Kamal::Configuration::Validation - CORD_FILE = "cord" delegate :argumentize, :optionize, to: Kamal::Utils - attr_reader :name, :config, :specialized_env, :specialized_logging, :specialized_healthcheck + attr_reader :name, :config, :specialized_env, :specialized_logging, :specialized_proxy alias to_s name @@ -25,9 +24,7 @@ class Kamal::Configuration::Role logging_config: specializations.fetch("logging", {}), context: "servers/#{name}/logging" - @specialized_healthcheck = Kamal::Configuration::Healthcheck.new \ - healthcheck_config: specializations.fetch("healthcheck", {}), - context: "servers/#{name}/healthcheck" + initialize_specialized_proxy end def primary_host @@ -55,7 +52,7 @@ class Kamal::Configuration::Role end def labels - default_labels.merge(traefik_labels).merge(custom_labels) + default_labels.merge(custom_labels) end def label_args @@ -70,6 +67,24 @@ class Kamal::Configuration::Role @logging ||= config.logging.merge(specialized_logging) end + def proxy + @proxy ||= config.proxy.merge(specialized_proxy) if running_proxy? + end + + def running_proxy? + @running_proxy + end + + def ssl? + running_proxy? && proxy.ssl? + end + + def stop_args + # When deploying with the proxy, kamal-proxy will drain request before returning so we don't need to wait. + timeout = running_proxy? ? nil : config.drain_timeout + + [ *argumentize("-t", timeout) ] + end def env(host) @envs ||= {} @@ -89,7 +104,7 @@ class Kamal::Configuration::Role end def secrets_path - File.join(config.env_directory, "roles", "#{container_prefix}.env") + File.join(config.env_directory, "roles", "#{name}.env") end def asset_volume_args @@ -97,72 +112,8 @@ class Kamal::Configuration::Role end - def health_check_args(cord: true) - if running_traefik? || healthcheck.set_port_or_path? - if cord && uses_cord? - optionize({ "health-cmd" => health_check_cmd_with_cord, "health-interval" => healthcheck.interval }) - .concat(cord_volume.docker_args) - else - optionize({ "health-cmd" => healthcheck.cmd, "health-interval" => healthcheck.interval }) - end - else - [] - end - end - - def healthcheck - @healthcheck ||= - if running_traefik? - config.healthcheck.merge(specialized_healthcheck) - else - specialized_healthcheck - end - end - - def health_check_cmd_with_cord - "(#{healthcheck.cmd}) && (stat #{cord_container_file} > /dev/null || exit 1)" - end - - - def running_traefik? - if specializations["traefik"].nil? - primary? - else - specializations["traefik"] - end - end - def primary? - self == @config.primary_role - end - - - def uses_cord? - running_traefik? && cord_volume && healthcheck.cmd.present? - end - - def cord_host_directory - File.join config.run_directory_as_docker_volume, "cords", [ container_prefix, config.run_id ].join("-") - end - - def cord_volume - if (cord = healthcheck.cord) - @cord_volume ||= Kamal::Configuration::Volume.new \ - host_path: File.join(config.run_directory, "cords", [ container_prefix, config.run_id ].join("-")), - container_path: cord - end - end - - def cord_host_file - File.join cord_volume.host_path, CORD_FILE - end - - def cord_container_directory - health_check_options.fetch("cord", nil) - end - - def cord_container_file - File.join cord_volume.container_path, CORD_FILE + name == @config.primary_role_name end @@ -180,25 +131,52 @@ class Kamal::Configuration::Role end def assets? - asset_path.present? && running_traefik? + asset_path.present? && running_proxy? end - def asset_volume(version = nil) + def asset_volume(version = config.version) if assets? Kamal::Configuration::Volume.new \ - host_path: asset_volume_path(version), container_path: asset_path + host_path: asset_volume_directory(version), container_path: asset_path end end - def asset_extracted_path(version = nil) - File.join config.run_directory, "assets", "extracted", container_name(version) + def asset_extracted_directory(version = config.version) + File.join config.assets_directory, "extracted", [ name, version ].join("-") end - def asset_volume_path(version = nil) - File.join config.run_directory, "assets", "volumes", container_name(version) + def asset_volume_directory(version = config.version) + File.join config.assets_directory, "volumes", [ name, version ].join("-") + end + + def ensure_one_host_for_ssl + if running_proxy? && proxy.ssl? && hosts.size > 1 + raise Kamal::ConfigurationError, "SSL is only supported on a single server, found #{hosts.size} servers for role #{name}" + end end private + def initialize_specialized_proxy + proxy_specializations = specializations["proxy"] + + if primary? + # only false means no proxy for non-primary roles + @running_proxy = proxy_specializations != false + else + # false and nil both mean no proxy for non-primary roles + @running_proxy = !!proxy_specializations + end + + if running_proxy? + proxy_config = proxy_specializations == true || proxy_specializations.nil? ? {} : proxy_specializations + + @specialized_proxy = Kamal::Configuration::Proxy.new \ + config: config, + proxy_config: proxy_config, + context: "servers/#{name}/proxy" + end + end + def tagged_hosts {}.tap do |tagged_hosts| extract_hosts_from_config.map do |host_config| @@ -233,27 +211,6 @@ class Kamal::Configuration::Role end end - def traefik_labels - if running_traefik? - { - # Setting a service property ensures that the generated service name will be consistent between versions - "traefik.http.services.#{traefik_service}.loadbalancer.server.scheme" => "http", - - "traefik.http.routers.#{traefik_service}.rule" => "PathPrefix(`/`)", - "traefik.http.routers.#{traefik_service}.priority" => "2", - "traefik.http.middlewares.#{traefik_service}-retry.retry.attempts" => "5", - "traefik.http.middlewares.#{traefik_service}-retry.retry.initialinterval" => "500ms", - "traefik.http.routers.#{traefik_service}.middlewares" => "#{traefik_service}-retry@docker" - } - else - {} - end - end - - def traefik_service - container_prefix - end - def custom_labels Hash.new.tap do |labels| labels.merge!(config.labels) if config.labels.present? diff --git a/lib/kamal/configuration/traefik.rb b/lib/kamal/configuration/traefik.rb deleted file mode 100644 index 45d8bac5..00000000 --- a/lib/kamal/configuration/traefik.rb +++ /dev/null @@ -1,78 +0,0 @@ -class Kamal::Configuration::Traefik - delegate :argumentize, to: Kamal::Utils - - DEFAULT_IMAGE = "traefik:v2.10" - CONTAINER_PORT = 80 - DEFAULT_ARGS = { - "log.level" => "DEBUG" - } - DEFAULT_LABELS = { - # These ensure we serve a 502 rather than a 404 if no containers are available - "traefik.http.routers.catchall.entryPoints" => "http", - "traefik.http.routers.catchall.rule" => "PathPrefix(`/`)", - "traefik.http.routers.catchall.service" => "unavailable", - "traefik.http.routers.catchall.priority" => 1, - "traefik.http.services.unavailable.loadbalancer.server.port" => "0" - } - - include Kamal::Configuration::Validation - - attr_reader :config, :traefik_config - - def initialize(config:) - @config = config - @traefik_config = config.raw_config.traefik || {} - validate! traefik_config - end - - def publish? - traefik_config["publish"] != false - end - - def labels - DEFAULT_LABELS.merge(traefik_config["labels"] || {}) - end - - def env - Kamal::Configuration::Env.new \ - config: traefik_config.fetch("env", {}), - secrets: config.secrets, - context: "traefik/env" - end - - def host_port - traefik_config.fetch("host_port", CONTAINER_PORT) - end - - def options - traefik_config.fetch("options", {}) - end - - def port - "#{host_port}:#{CONTAINER_PORT}" - end - - def args - DEFAULT_ARGS.merge(traefik_config.fetch("args", {})) - end - - def image - traefik_config.fetch("image", DEFAULT_IMAGE) - 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 diff --git a/lib/kamal/configuration/validator.rb b/lib/kamal/configuration/validator.rb index 2ac8482d..0d46e4ae 100644 --- a/lib/kamal/configuration/validator.rb +++ b/lib/kamal/configuration/validator.rb @@ -24,7 +24,9 @@ class Kamal::Configuration::Validator example_value = example[key] if example_value == "..." - validate_type! value, *(Array if key == :servers), Hash + unless key.to_s == "proxy" && boolean?(value.class) + validate_type! value, *(Array if key == :servers), Hash + end elsif key == "hosts" validate_servers! value elsif example_value.is_a?(Array) diff --git a/lib/kamal/configuration/validator/proxy.rb b/lib/kamal/configuration/validator/proxy.rb new file mode 100644 index 00000000..bf2e5e9e --- /dev/null +++ b/lib/kamal/configuration/validator/proxy.rb @@ -0,0 +1,11 @@ +class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator + def validate! + unless config.nil? + super + + if config["host"].blank? && config["ssl"] + error "Must set a host to enable automatic SSL" + end + end + end +end diff --git a/lib/kamal/utils.rb b/lib/kamal/utils.rb index 266d6a96..ab8dd50e 100644 --- a/lib/kamal/utils.rb +++ b/lib/kamal/utils.rb @@ -101,4 +101,8 @@ module Kamal::Utils arch end end + + def older_version?(version, other_version) + Gem::Version.new(version.delete_prefix("v")) < Gem::Version.new(other_version.delete_prefix("v")) + end end diff --git a/lib/kamal/version.rb b/lib/kamal/version.rb index 4c771efd..46616deb 100644 --- a/lib/kamal/version.rb +++ b/lib/kamal/version.rb @@ -1,3 +1,3 @@ module Kamal - VERSION = "2.0.0.alpha" + VERSION = "2.0.0.rc2" end diff --git a/test/cli/accessory_test.rb b/test/cli/accessory_test.rb index 0e3abc46..b8da2a7a 100644 --- a/test/cli/accessory_test.rb +++ b/test/cli/accessory_test.rb @@ -15,7 +15,7 @@ class CliAccessoryTest < CliTestCase run_command("boot", "mysql").tap do |output| assert_match /docker login.*on 1.1.1.3/, output - assert_match "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 --env 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 --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", output end end @@ -29,9 +29,12 @@ class CliAccessoryTest < CliTestCase assert_match /docker login.*on 1.1.1.3/, output assert_match /docker login.*on 1.1.1.1/, output assert_match /docker login.*on 1.1.1.2/, output - assert_match "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 --env 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 --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output - assert_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output + assert_match /docker network create kamal.*on 1.1.1.1/, output + assert_match /docker network create kamal.*on 1.1.1.2/, output + assert_match /docker network create kamal.*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/apps/app/env/accessories/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/apps/app/env/accessories/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/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output end end @@ -51,7 +54,7 @@ class CliAccessoryTest < CliTestCase Kamal::Commands::Registry.any_instance.expects(:login) Kamal::Cli::Accessory.any_instance.expects(:stop).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("mysql") - Kamal::Cli::Accessory.any_instance.expects(:boot).with("mysql", login: false) + Kamal::Cli::Accessory.any_instance.expects(:boot).with("mysql", prepare: false) run_command("reboot", "mysql") end @@ -60,10 +63,10 @@ class CliAccessoryTest < CliTestCase Kamal::Commands::Registry.any_instance.expects(:login).times(3) Kamal::Cli::Accessory.any_instance.expects(:stop).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("mysql") - Kamal::Cli::Accessory.any_instance.expects(:boot).with("mysql", login: false) + Kamal::Cli::Accessory.any_instance.expects(:boot).with("mysql", prepare: false) Kamal::Cli::Accessory.any_instance.expects(:stop).with("redis") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("redis") - Kamal::Cli::Accessory.any_instance.expects(:boot).with("redis", login: false) + Kamal::Cli::Accessory.any_instance.expects(:boot).with("redis", prepare: false) run_command("reboot", "all") end @@ -200,8 +203,8 @@ class CliAccessoryTest < CliTestCase run_command("boot", "redis", "--hosts", "1.1.1.1").tap do |output| assert_match /docker login.*on 1.1.1.1/, output assert_no_match /docker login.*on 1.1.1.2/, output - assert_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output - assert_no_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output + assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/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/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output end end @@ -212,11 +215,32 @@ class CliAccessoryTest < CliTestCase run_command("boot", "redis", "--hosts", "1.1.1.1,1.1.1.3").tap do |output| assert_match /docker login.*on 1.1.1.1/, output assert_no_match /docker login.*on 1.1.1.3/, output - assert_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output - assert_no_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.3", output + assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/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/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.3", output end end + test "upgrade" do + run_command("upgrade", "-y", "all").tap do |output| + assert_match "Upgrading all accessories on 1.1.1.3,1.1.1.1,1.1.1.2...", output + assert_match "docker network create kamal on 1.1.1.3", output + assert_match "docker container stop app-mysql 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/apps/app/env/accessories/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 "Upgraded all accessories on 1.1.1.3,1.1.1.1,1.1.1.2...", output + end + end + + test "upgrade rolling" do + run_command("upgrade", "--rolling", "-y", "all").tap do |output| + assert_match "Upgrading all accessories on 1.1.1.3...", output + assert_match "docker network create kamal on 1.1.1.3", output + assert_match "docker container stop app-mysql 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/apps/app/env/accessories/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 "Upgraded all accessories on 1.1.1.3", output + end + end + + private def run_command(*command) stdouted { Kamal::Cli::Accessory.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index 85a966fd..7c208da2 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -5,7 +5,7 @@ class CliAppTest < CliTestCase stub_running run_command("boot").tap do |output| assert_match "docker tag dhh/app:latest dhh/app:latest", output - assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname 1.1.1.1-[0-9a-f]{12} /, output + assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output end end @@ -18,26 +18,18 @@ class CliAppTest < CliTestCase .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", raise_on_non_zero_exit: false) .returns("12345678") # running version - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") - .returns("running") # health check - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .returns("123") # old version SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :inspect, "-f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}'", "app-web-123", "|", :awk, "'$2 == \"/tmp/kamal-cord\" {print $1}'", raise_on_non_zero_exit: false) - .returns("cordfile") # old version - - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") - .returns("unhealthy") # old version unhealthy + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet") + .returns("12345678") # running version run_command("boot").tap do |output| assert_match /Renaming container .* to .* as already deployed on 1.1.1.1/, output # Rename assert_match /docker rename app-web-latest app-web-latest_replaced_[0-9a-f]{16}/, output - assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname 1.1.1.1-[0-9a-f]{12} /, output + assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output end ensure @@ -45,7 +37,7 @@ class CliAppTest < CliTestCase end test "boot uses group strategy when specified" do - Kamal::Cli::App.any_instance.stubs(:on).with("1.1.1.1").times(3) # ensure locks dir, acquire & release lock + Kamal::Cli::App.any_instance.stubs(:on).with("1.1.1.1").times(2) # ensure locks dir, acquire & release lock Kamal::Cli::App.any_instance.stubs(:on).with([ "1.1.1.1" ]) # tag container # Strategy is used when booting the containers @@ -70,25 +62,21 @@ class CliAppTest < CliTestCase .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", raise_on_non_zero_exit: false) .returns("12345678") # running version - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") - .returns("running") # health check - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .returns("123").twice # old version SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :inspect, "-f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}'", "app-web-123", "|", :awk, "'$2 == \"/tmp/kamal-cord\" {print $1}'", raise_on_non_zero_exit: false) - .returns("") # old version + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet") + .returns("12345678") # running version run_command("boot", config: :with_assets).tap do |output| assert_match "docker tag dhh/app:latest dhh/app:latest", output - assert_match "/usr/bin/env mkdir -p .kamal/assets/volumes/app-web-latest ; cp -rnT .kamal/assets/extracted/app-web-latest .kamal/assets/volumes/app-web-latest ; cp -rnT .kamal/assets/extracted/app-web-latest .kamal/assets/volumes/app-web-123 || true ; cp -rnT .kamal/assets/extracted/app-web-123 .kamal/assets/volumes/app-web-latest || true", output - assert_match "/usr/bin/env mkdir -p .kamal/assets/extracted/app-web-latest && docker stop -t 1 app-web-assets 2> /dev/null || true && docker run --name app-web-assets --detach --rm --entrypoint sleep dhh/app:latest 1000000 && docker cp -L app-web-assets:/public/assets/. .kamal/assets/extracted/app-web-latest && docker stop -t 1 app-web-assets", output - assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname 1.1.1.1-[0-9a-f]{12} /, output + assert_match "/usr/bin/env mkdir -p .kamal/apps/app/assets/volumes/web-latest ; cp -rnT .kamal/apps/app/assets/extracted/web-latest .kamal/apps/app/assets/volumes/web-latest ; cp -rnT .kamal/apps/app/assets/extracted/web-latest .kamal/apps/app/assets/volumes/web-123 || true ; cp -rnT .kamal/apps/app/assets/extracted/web-123 .kamal/apps/app/assets/volumes/web-latest || true", output + assert_match "/usr/bin/env mkdir -p .kamal/apps/app/assets/extracted/web-latest && docker stop -t 1 app-web-assets 2> /dev/null || true && docker run --name app-web-assets --detach --rm --entrypoint sleep dhh/app:latest 1000000 && docker cp -L app-web-assets:/public/assets/. .kamal/apps/app/assets/extracted/web-latest && docker stop -t 1 app-web-assets", output + assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output - assert_match "/usr/bin/env find .kamal/assets/extracted -maxdepth 1 -name 'app-web-*' ! -name app-web-latest -exec rm -rf \"{}\" + ; find .kamal/assets/volumes -maxdepth 1 -name 'app-web-*' ! -name app-web-latest -exec rm -rf \"{}\" +", output + assert_match "/usr/bin/env find .kamal/apps/app/assets/extracted -maxdepth 1 -name 'web-*' ! -name web-latest -exec rm -rf \"{}\" + ; find .kamal/apps/app/assets/volumes -maxdepth 1 -name 'web-*' ! -name web-latest -exec rm -rf \"{}\" +", output end end @@ -96,24 +84,20 @@ class CliAppTest < CliTestCase Object.any_instance.stubs(:sleep) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", raise_on_non_zero_exit: false) - .returns("12345678") # running version + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", raise_on_non_zero_exit: false) + .returns("12345678") # running version SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") - .returns("running") # health check + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet") + .returns("12345678") # running version SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .returns("123") # old version - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :inspect, "-f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}'", "app-web-123", "|", :awk, "'$2 == \"/tmp/kamal-cord\" {print $1}'", raise_on_non_zero_exit: false) - .returns("") # old version - run_command("boot", config: :with_env_tags).tap do |output| assert_match "docker tag dhh/app:latest dhh/app:latest", output - assert_match %r{docker run --detach --restart unless-stopped --name app-web-latest --hostname 1.1.1.1-[0-9a-f]{12} -e KAMAL_CONTAINER_NAME="app-web-latest" -e KAMAL_VERSION="latest" --env TEST="root" --env EXPERIMENT="disabled" --env SITE="site1"}, output + assert_match %r{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 TEST="root" --env EXPERIMENT="disabled" --env SITE="site1"}, output assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output end end @@ -123,14 +107,6 @@ class CliAppTest < CliTestCase SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") - .returns("running").at_least_once # web health check passing - - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") - .returns("unhealthy").at_least_once # web health check failing - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "name=^app-workers-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") .returns("running").at_least_once # workers health check @@ -140,7 +116,7 @@ class CliAppTest < CliTestCase assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.4...", output assert_match "First web container is healthy, booting workers on 1.1.1.3", output assert_match "First web container is healthy, booting workers on 1.1.1.4", output - end + end end test "boot with web barrier closed" do @@ -150,9 +126,11 @@ class CliAppTest < CliTestCase SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") - .returns("unhealthy").at_least_once # web health check failing + SSHKit::Backend::Abstract.any_instance.stubs(:execute).returns("") + SSHKit::Backend::Abstract.any_instance.stubs(:execute) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :stop, raise_on_non_zero_exit: false) + SSHKit::Backend::Abstract.any_instance.expects(:execute) + .with(:docker, :exec, "kamal-proxy", "kamal-proxy", :deploy, "app-web", "--target", "\"123:80\"", "--deploy-timeout", "\"1s\"", "--drain-timeout", "\"30s\"", "--buffer-requests", "--buffer-responses", "--log-request-header", "\"Cache-Control\"", "--log-request-header", "\"Last-Modified\"", "--log-request-header", "\"User-Agent\"").raises(SSHKit::Command::Failed.new("Failed to deploy")) stderred do run_command("boot", config: :with_roles, host: nil, allow_execute_error: true).tap do |output| @@ -160,17 +138,59 @@ class CliAppTest < CliTestCase assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.4...", output assert_match "First web container is unhealthy, not booting workers on 1.1.1.3", output assert_match "First web container is unhealthy, not booting workers on 1.1.1.4", output - assert_match "Running docker container ls --all --filter name=^app-web-latest$ --quiet | xargs docker stop on 1.1.1.1", output - assert_match "Running docker container ls --all --filter name=^app-web-latest$ --quiet | xargs docker stop on 1.1.1.2", output end end ensure Thread.report_on_exception = true end + test "boot with worker errors" do + Thread.report_on_exception = false + + Object.any_instance.stubs(:sleep) + + SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-workers-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") + .returns("unhealthy").at_least_once # workers health check + + run_command("boot", config: :with_roles, host: nil, allow_execute_error: true).tap do |output| + assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.3...", output + assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.4...", output + assert_match "First web container is healthy, booting workers on 1.1.1.3", output + assert_match "First web container is healthy, booting workers on 1.1.1.4", output + assert_match "ERROR Failed to boot workers on 1.1.1.3", output + assert_match "ERROR Failed to boot workers on 1.1.1.4", output + end + ensure + Thread.report_on_exception = true + end + + test "boot with worker ready then not" do + Thread.report_on_exception = false + + Object.any_instance.stubs(:sleep) + + SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-workers-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") + .returns("running", "stopped").at_least_once # workers health check + + run_command("boot", config: :with_roles, host: "1.1.1.3", allow_execute_error: true).tap do |output| + assert_match "ERROR Failed to boot workers on 1.1.1.3", output + end + ensure + Thread.report_on_exception = true + end + test "start" do + SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("999") # old version + run_command("start").tap do |output| assert_match "docker start app-web-999", output + assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target \"999:80\" --deploy-timeout \"30s\" --drain-timeout \"30s\" --buffer-requests --buffer-responses --log-request-header \"Cache-Control\" --log-request-header \"Last-Modified\"", output end end @@ -243,13 +263,13 @@ class CliAppTest < CliTestCase test "exec" do run_command("exec", "ruby -v").tap do |output| - assert_match "docker run --rm --env-file .kamal/env/roles/app-web.env dhh/app:latest ruby -v", output + assert_match "docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env dhh/app:latest ruby -v", output end end test "exec separate arguments" do run_command("exec", "ruby", " -v").tap do |output| - assert_match "docker run --rm --env-file .kamal/env/roles/app-web.env dhh/app:latest ruby -v", output + assert_match "docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env dhh/app:latest ruby -v", output end end @@ -262,7 +282,7 @@ class CliAppTest < CliTestCase test "exec interactive" do SSHKit::Backend::Abstract.any_instance.expects(:exec) - .with("ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --env-file .kamal/env/roles/app-web.env dhh/app:latest ruby -v'") + .with("ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env dhh/app:latest ruby -v'") run_command("exec", "-i", "ruby -v").tap do |output| assert_match "Get most recent version available as an image...", output assert_match "Launching interactive command with version latest via SSH from new container on 1.1.1.1...", output @@ -295,11 +315,11 @@ class CliAppTest < CliTestCase SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.1 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1| xargs docker logs --timestamps --tail 10 2>&1'") - assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --tail 100 2>&1", run_command("logs") + assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 100 2>&1", run_command("logs") - assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1 | grep 'hey'", run_command("logs", "--grep", "hey") + assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'hey'", run_command("logs", "--grep", "hey") - assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1 | grep 'hey' -C 2", run_command("logs", "--grep", "hey", "--grep-options", "-C 2") + assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'hey' -C 2", run_command("logs", "--grep", "hey", "--grep-options", "-C 2") end test "logs with follow" do @@ -342,7 +362,7 @@ class CliAppTest < CliTestCase hostname = "this-hostname-is-really-unacceptably-long-to-be-honest.example.com" stdouted { Kamal::Cli::App.start([ "boot", "-c", "test/fixtures/deploy_with_uncommon_hostnames.yml", "--hosts", hostname ]) }.tap do |output| - assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname this-hostname-is-really-unacceptably-long-to-be-hon-[0-9a-f]{12} /, output + assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname this-hostname-is-really-unacceptably-long-to-be-hon-[0-9a-f]{12} /, output end end @@ -352,7 +372,28 @@ class CliAppTest < CliTestCase hostname = "this-hostname-with-random-part-is-too-long.example.com" stdouted { Kamal::Cli::App.start([ "boot", "-c", "test/fixtures/deploy_with_uncommon_hostnames.yml", "--hosts", hostname ]) }.tap do |output| - assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname this-hostname-with-random-part-is-too-long.example-[0-9a-f]{12} /, output + assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname this-hostname-with-random-part-is-too-long.example-[0-9a-f]{12} /, output + end + end + + test "boot proxy" do + SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version + + 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 /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\/apps\/app\/env\/roles\/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 container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output + end + end + + test "boot proxy with role specific config" do + SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version + + run_command("boot", config: :with_proxy_roles, host: nil).tap do |output| + assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target \"123:80\" --deploy-timeout \"6s\" --drain-timeout \"30s\" --target-timeout \"10s\" --buffer-requests --buffer-responses --log-request-header \"Cache-Control\" --log-request-header \"Last-Modified\" --log-request-header \"User-Agent\"", output + assert_match "docker exec kamal-proxy kamal-proxy deploy app-web2 --target \"123:80\" --deploy-timeout \"6s\" --drain-timeout \"30s\" --target-timeout \"15s\" --buffer-requests --buffer-responses --log-request-header \"Cache-Control\" --log-request-header \"Last-Modified\" --log-request-header \"User-Agent\"", output end end @@ -369,13 +410,5 @@ class CliAppTest < CliTestCase Object.any_instance.stubs(:sleep) SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version - - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") - .returns("running") # health check - - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") - .returns("unhealthy") # health check end end diff --git a/test/cli/build_test.rb b/test/cli/build_test.rb index 2d5d1051..4259fa5b 100644 --- a/test/cli/build_test.rb +++ b/test/cli/build_test.rb @@ -49,7 +49,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(: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", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :"rev-parse", :HEAD) @@ -140,7 +140,7 @@ class CliBuildTest < CliTestCase .returns("") 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", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".") run_command("push").tap do |output| assert_match /WARN Missing compatible builder, so creating a new one first/, output diff --git a/test/cli/cli_test_case.rb b/test/cli/cli_test_case.rb index 3f3e9294..27bf7b69 100644 --- a/test/cli/cli_test_case.rb +++ b/test/cli/cli_test_case.rb @@ -29,39 +29,19 @@ class CliTestCase < ActiveSupport::TestCase def stub_setup SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with { |*args| args == [ :mkdir, "-p", ".kamal" ] } + .with { |*args| args == [ :mkdir, "-p", ".kamal/apps/app" ] } SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with { |arg1, arg2, arg3| arg1 == :mkdir && arg2 == "-p" && arg3 == ".kamal/locks" } + .with { |arg1, arg2, arg3| arg1 == :mkdir && arg2 == "-p" && arg3 == ".kamal/lock-app" } SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with { |arg1, arg2| arg1 == :mkdir && arg2 == ".kamal/locks/app" } + .with { |arg1, arg2| arg1 == :mkdir && arg2 == ".kamal/lock-app" } SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with { |arg1, arg2| arg1 == :rm && arg2 == ".kamal/locks/app/details" } + .with { |arg1, arg2| arg1 == :rm && arg2 == ".kamal/lock-app/details" } SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with(:docker, :buildx, :inspect, "kamal-local-docker-container") end def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, subcommand: nil, runtime: false, secrets: false) - whoami = `whoami`.chomp - performer = Kamal::Git.email.presence || whoami - service = service_version.split("@").first - - assert_match "Running the #{hook} hook...\n", output - - expected = %r{Running\s/usr/bin/env\s\.kamal/hooks/#{hook}\sas\s#{whoami}@localhost\n\s - DEBUG\s\[[0-9a-f]*\]\sCommand:\s\(\sexport\s - KAMAL_RECORDED_AT=\"\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\dZ\"\s - KAMAL_PERFORMER=\"#{performer}\"\s - KAMAL_VERSION=\"#{version}\"\s - KAMAL_SERVICE_VERSION=\"#{service_version}\"\s - KAMAL_SERVICE=\"#{service}\"\s - KAMAL_HOSTS=\"#{hosts}\"\s - KAMAL_COMMAND=\"#{command}\"\s - #{"KAMAL_SUBCOMMAND=\\\"#{subcommand}\\\"\\s" if subcommand} - #{"KAMAL_RUNTIME=\\\"\\d+\\\"\\s" if runtime} - #{"DB_PASSWORD=\"secret\"\\s" if secrets} - ;\s/usr/bin/env\s\.kamal/hooks/#{hook} }x - - assert_match expected, output + assert_match %r{usr/bin/env\s\.kamal/hooks/#{hook}}, output end def with_argv(*argv) diff --git a/test/cli/lock_test.rb b/test/cli/lock_test.rb index f6874d49..66482153 100644 --- a/test/cli/lock_test.rb +++ b/test/cli/lock_test.rb @@ -3,7 +3,7 @@ require_relative "cli_test_case" class CliLockTest < CliTestCase test "status" do run_command("status").tap do |output| - assert_match "Running /usr/bin/env stat .kamal/locks/app > /dev/null && cat .kamal/locks/app/details | base64 -d on 1.1.1.1", output + assert_match "Running /usr/bin/env stat .kamal/lock-app > /dev/null && cat .kamal/lock-app/details | base64 -d on 1.1.1.1", output end end diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 2115f418..4b111bad 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -24,7 +24,7 @@ class CliMainTest < CliTestCase # 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:build:pull", [], invoke_options) - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], invoke_options) + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) @@ -35,7 +35,7 @@ class CliMainTest < CliTestCase assert_match /Acquiring the deploy lock/, output assert_match /Log into image registry/, output assert_match /Pull app image/, output - assert_match /Ensure Traefik is running/, output + assert_match /Ensure kamal-proxy is running/, output assert_match /Detect stale containers/, output assert_match /Prune old containers and images/, output assert_match /Releasing the deploy lock/, output @@ -48,7 +48,7 @@ class CliMainTest < CliTestCase 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:traefik:boot", [], invoke_options) + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) @@ -61,7 +61,7 @@ class CliMainTest < CliTestCase assert_match /Log into image registry/, output assert_match /Build and push app image/, output assert_hook_ran "pre-deploy", output, **hook_variables, secrets: true - assert_match /Ensure Traefik is running/, output + assert_match /Ensure kamal-proxy is running/, output assert_match /Detect stale containers/, output assert_match /Prune old containers and images/, output assert_hook_ran "post-deploy", output, **hook_variables, runtime: true, secrets: true @@ -74,7 +74,7 @@ class CliMainTest < CliTestCase 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:build:pull", [], invoke_options) - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], invoke_options) + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) @@ -83,7 +83,7 @@ class CliMainTest < CliTestCase assert_match /Acquiring the deploy lock/, output assert_match /Log into image registry/, output assert_match /Pull app image/, output - assert_match /Ensure Traefik is running/, output + assert_match /Ensure kamal-proxy is running/, output assert_match /Detect stale containers/, output assert_match /Prune old containers and images/, output assert_match /Releasing the deploy lock/, output @@ -97,17 +97,14 @@ class CliMainTest < CliTestCase Dir.stubs(:chdir) SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with { |*args| args == [ :mkdir, "-p", ".kamal" ] } + .with { |*args| args == [ :mkdir, "-p", ".kamal/apps/app" ] } SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with { |*args| args == [ :mkdir, "-p", ".kamal/locks" ] } - - SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with { |*arg| arg[0..1] == [ :mkdir, ".kamal/locks/app" ] } - .raises(RuntimeError, "mkdir: cannot create directory ‘kamal/locks/app’: File exists") + .with { |*arg| arg[0..1] == [ :mkdir, ".kamal/lock-app" ] } + .raises(RuntimeError, "mkdir: cannot create directory ‘kamal/lock-app’: File exists") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_debug) - .with(:stat, ".kamal/locks/app", ">", "/dev/null", "&&", :cat, ".kamal/locks/app/details", "|", :base64, "-d") + .with(:stat, ".kamal/lock-app", ">", "/dev/null", "&&", :cat, ".kamal/lock-app/details", "|", :base64, "-d") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :"rev-parse", :HEAD) @@ -134,13 +131,10 @@ class CliMainTest < CliTestCase Dir.stubs(:chdir) SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with { |*args| args == [ :mkdir, "-p", ".kamal" ] } + .with { |*args| args == [ :mkdir, "-p", ".kamal/apps/app" ] } SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with { |*args| args == [ :mkdir, "-p", ".kamal/locks" ] } - - SSHKit::Backend::Abstract.any_instance.stubs(:execute) - .with { |*arg| arg[0..1] == [ :mkdir, ".kamal/locks/app" ] } + .with { |*arg| arg[0..1] == [ :mkdir, ".kamal/lock-app" ] } .raises(SocketError, "getaddrinfo: nodename nor servname provided, or not known") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) @@ -180,7 +174,7 @@ class CliMainTest < CliTestCase 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:traefik:boot", [], invoke_options) + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) @@ -190,27 +184,12 @@ class CliMainTest < CliTestCase end end - test "deploy without healthcheck if primary host doesn't have traefik" do - invoke_options = { "config_file" => "test/fixtures/deploy_workers_only.yml", "version" => "999", "skip_hooks" => false } - - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options).never - - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.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:traefik:boot", [], invoke_options) - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) - - run_command("deploy", config_file: "deploy_workers_only") - end - test "deploy with missing secrets" do invoke_options = { "config_file" => "test/fixtures/deploy_with_secrets.yml", "version" => "999", "skip_hooks" => false } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.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:traefik:boot", [], invoke_options) + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) @@ -273,18 +252,11 @@ class CliMainTest < CliTestCase SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=#{role} --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=#{role} --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-#{role}-}; done", raise_on_non_zero_exit: false) .returns("version-to-rollback\n").at_least_once - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") - .returns("running").at_least_once # health check end SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :inspect, "-f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}'", "app-web-version-to-rollback", "|", :awk, "'$2 == \"/tmp/kamal-cord\" {print $1}'", raise_on_non_zero_exit: false) - .returns("corddirectory").at_least_once # health check - - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-version-to-rollback$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") - .returns("unhealthy").at_least_once # health check + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-workers-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") + .returns("running").at_least_once # health check Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) hook_variables = { version: 123, service_version: "app@123", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "rollback" } @@ -301,17 +273,15 @@ class CliMainTest < CliTestCase test "rollback without old version" do Kamal::Cli::Main.any_instance.stubs(:container_available?).returns(true) - Kamal::Cli::Healthcheck::Poller.stubs(:sleep) - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", raise_on_non_zero_exit: false) .returns("").at_least_once + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet") + .returns("123").at_least_once SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .returns("").at_least_once - SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) - .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") - .returns("running").at_least_once # health check run_command("rollback", "123").tap do |output| assert_match "docker run --detach --restart unless-stopped --name app-web-123", output @@ -320,7 +290,7 @@ class CliMainTest < CliTestCase end test "details" do - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:details") + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:details") Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:details") Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:details", [ "all" ]) @@ -434,13 +404,14 @@ class CliMainTest < CliTestCase test "remove with confirmation" do run_command("remove", "-y", config_file: "deploy_with_accessories").tap do |output| - assert_match /docker container stop traefik/, output - assert_match /docker container prune --force --filter label=org.opencontainers.image.title=Traefik/, output - assert_match /docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik/, output + assert_match /docker container stop kamal-proxy/, output + assert_match /docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy/, output + assert_match /docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy/, output assert_match /docker ps --quiet --filter label=service=app | xargs docker stop/, output assert_match /docker container prune --force --filter label=service=app/, output assert_match /docker image prune --all --force --filter label=service=app/, output + assert_match "/usr/bin/env rm -r .kamal/apps/app", output assert_match /docker container stop app-mysql/, output assert_match /docker container prune --force --filter label=service=app-mysql/, output @@ -480,7 +451,7 @@ class CliMainTest < CliTestCase end test "run an alias for details" do - Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:details") + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:details") Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:details") Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:details", [ "all" ]) @@ -515,6 +486,34 @@ class CliMainTest < CliTestCase end end + test "upgrade" do + invoke_options = { "config_file" => "test/fixtures/deploy_with_accessories.yml", "skip_hooks" => false, "confirmed" => true, "rolling" => false } + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:upgrade", [], invoke_options) + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:upgrade", [ "all" ], invoke_options) + + run_command("upgrade", "-y", config_file: "deploy_with_accessories").tap do |output| + assert_match "Upgrading all hosts...", output + assert_match "Upgraded all hosts", output + end + end + + test "upgrade rolling" do + invoke_options = { "config_file" => "test/fixtures/deploy_with_accessories.yml", "skip_hooks" => false, "confirmed" => true, "rolling" => false } + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:upgrade", [], invoke_options).times(4) + Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:upgrade", [ "all" ], invoke_options).times(3) + + run_command("upgrade", "--rolling", "-y", config_file: "deploy_with_accessories").tap do |output| + assert_match "Upgrading 1.1.1.1...", output + assert_match "Upgraded 1.1.1.1", output + assert_match "Upgrading 1.1.1.2...", output + assert_match "Upgraded 1.1.1.2", output + assert_match "Upgrading 1.1.1.3...", output + assert_match "Upgraded 1.1.1.3", output + assert_match "Upgrading 1.1.1.4...", output + assert_match "Upgraded 1.1.1.4", output + end + end + private def run_command(*command, config_file: "deploy_simple") with_argv([ *command, "-c", "test/fixtures/#{config_file}.yml" ]) do diff --git a/test/cli/proxy_test.rb b/test/cli/proxy_test.rb new file mode 100644 index 00000000..c9987a51 --- /dev/null +++ b/test/cli/proxy_test.rb @@ -0,0 +1,243 @@ +require_relative "cli_test_case" + +class CliProxyTest < CliTestCase + test "boot" do + run_command("boot").tap do |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 kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --log-opt max-size=\"10m\" #{KAMAL.config.proxy_image}", output + end + end + + test "boot old version" do + Thread.report_on_exception = false + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :cut, "-d:", "-f2") + .returns("v0.0.1") + .at_least_once + + exception = assert_raises do + run_command("boot").tap do |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 kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --log-opt max-size=\"10m\" #{KAMAL.config.proxy_image}", output + end + end + + assert_includes exception.message, "kamal-proxy version v0.0.1 is too old, please reboot to update to at least #{Kamal::Configuration::PROXY_MINIMUM_VERSION}" + ensure + Thread.report_on_exception = false + end + + test "boot correct version" do + Thread.report_on_exception = false + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :cut, "-d:", "-f2") + .returns(Kamal::Configuration::PROXY_MINIMUM_VERSION) + .at_least_once + + run_command("boot").tap do |output| + assert_match "docker login", output + assert_match "docker container start kamal-proxy || docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --log-opt max-size=\"10m\" #{KAMAL.config.proxy_image}", output + end + ensure + Thread.report_on_exception = false + end + + test "reboot" do + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet") + .returns("abcdefabcdef") + .at_least_once + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with { |*args| args[0..1] == [ :sh, "-c" ] } + .returns("123") + .at_least_once + + run_command("reboot", "-y").tap do |output| + assert_match "docker container stop kamal-proxy on 1.1.1.1", output + assert_match "Running docker container stop traefik ; docker container prune --force --filter label=org.opencontainers.image.title=Traefik && docker image prune --all --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=kamal-proxy 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 kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --log-opt max-size=\"10m\" #{KAMAL.config.proxy_image} on 1.1.1.1", output + assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target \"abcdefabcdef:80\" --deploy-timeout \"6s\" --drain-timeout \"30s\" --buffer-requests --buffer-responses --log-request-header \"Cache-Control\" --log-request-header \"Last-Modified\" --log-request-header \"User-Agent\" on 1.1.1.1", output + + assert_match "docker container stop kamal-proxy on 1.1.1.2", output + assert_match "Running docker container stop traefik ; docker container prune --force --filter label=org.opencontainers.image.title=Traefik && docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik on 1.1.1.2", output + assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.2", output + assert_match "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --log-opt max-size=\"10m\" #{KAMAL.config.proxy_image} on 1.1.1.2", output + assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target \"abcdefabcdef:80\" --deploy-timeout \"6s\" --drain-timeout \"30s\" --buffer-requests --buffer-responses --log-request-header \"Cache-Control\" --log-request-header \"Last-Modified\" --log-request-header \"User-Agent\" on 1.1.1.2", output + end + end + + test "reboot --rolling" do + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet") + .returns("abcdefabcdef") + .at_least_once + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with { |*args| args[0..1] == [ :sh, "-c" ] } + .returns("123") + .at_least_once + + run_command("reboot", "--rolling", "-y").tap do |output| + assert_match "Running docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.1", output + end + end + + test "start" do + run_command("start").tap do |output| + assert_match "docker container start kamal-proxy", output + end + end + + test "stop" do + run_command("stop").tap do |output| + assert_match "docker container stop kamal-proxy", output + end + end + + test "restart" do + Kamal::Cli::Proxy.any_instance.expects(:stop) + Kamal::Cli::Proxy.any_instance.expects(:start) + + run_command("restart") + end + + test "details" do + run_command("details").tap do |output| + assert_match "docker ps --filter name=^kamal-proxy$", output + end + end + + test "logs" do + SSHKit::Backend::Abstract.any_instance.stubs(:capture) + .with(:docker, :logs, "kamal-proxy", "--tail 100", "--timestamps", "2>&1") + .returns("Log entry") + + SSHKit::Backend::Abstract.any_instance.stubs(:capture) + .with(:docker, :logs, "proxy", "--tail 100", "--timestamps", "2>&1") + .returns("Log entry") + + run_command("logs").tap do |output| + assert_match "Proxy Host: 1.1.1.1", output + assert_match "Log entry", output + end + end + + test "logs with follow" do + SSHKit::Backend::Abstract.any_instance.stubs(:exec) + .with("ssh -t root@1.1.1.1 -p 22 'docker logs kamal-proxy --timestamps --tail 10 --follow 2>&1'") + + assert_match "docker logs kamal-proxy --timestamps --tail 10 --follow", run_command("logs", "--follow") + end + + test "remove" do + run_command("remove").tap do |output| + assert_match "/usr/bin/env ls .kamal/apps | wc -l", output + assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy", output + assert_match "docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy", output + end + end + + test "remove with other apps" do + Thread.report_on_exception = false + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:ls, ".kamal/apps", "|", :wc, "-l").returns("1\n").twice + + run_command("remove").tap do |output| + assert_match "Not removing the proxy, as other apps are installed, ignore this check with kamal proxy remove --force", output + end + ensure + Thread.report_on_exception = true + end + + test "force remove with other apps" do + Thread.report_on_exception = false + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:ls, ".kamal/apps", "|", :wc, "-l").returns("1\n").twice + + run_command("remove").tap do |output| + assert_match "Not removing the proxy, as other apps are installed, ignore this check with kamal proxy remove --force", output + end + ensure + Thread.report_on_exception = true + end + + test "remove_container" do + run_command("remove_container").tap do |output| + assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy", output + end + end + + test "remove_image" do + run_command("remove_image").tap do |output| + assert_match "docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy", output + end + end + + test "upgrade" do + Object.any_instance.stubs(:sleep) + + SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("12345678") + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :cut, "-d:", "-f2") + .returns(Kamal::Configuration::PROXY_MINIMUM_VERSION) + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-workers-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") + .returns("running").at_least_once # workers health check + + run_command("upgrade", "-y").tap do |output| + assert_match "Upgrading proxy on 1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4...", output + assert_match "docker login -u [REDACTED] -p [REDACTED]", output + assert_match "docker container stop traefik ; docker container prune --force --filter label=org.opencontainers.image.title=Traefik && docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik", output + assert_match "docker container stop kamal-proxy", output + assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy", output + assert_match "docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy", output + assert_match "/usr/bin/env mkdir -p .kamal", output + assert_match "docker network create kamal", output + assert_match "docker login -u [REDACTED] -p [REDACTED]", output + assert_match "docker container start kamal-proxy || docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --log-opt max-size=\"10m\" basecamp/kamal-proxy:#{Kamal::Configuration::PROXY_MINIMUM_VERSION}", output + assert_match "/usr/bin/env mkdir -p .kamal", output + assert_match %r{docker rename app-web-latest app-web-latest_replaced_.*}, output + assert_match "/usr/bin/env mkdir -p .kamal/apps/app/env/roles", output + assert_match %r{/usr/bin/env .* .kamal/apps/app/env/roles/web.env}, output + assert_match %r{docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-.* -e KAMAL_CONTAINER_NAME="app-web-latest" -e KAMAL_VERSION="latest" --env-file .kamal/apps/app/env/roles/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 \"12345678:80\" --deploy-timeout \"6s\" --drain-timeout \"30s\" --buffer-requests --buffer-responses --log-request-header \"Cache-Control\" --log-request-header \"Last-Modified\" --log-request-header \"User-Agent\"", output + assert_match "docker container ls --all --filter name=^app-web-12345678$ --quiet | xargs docker stop", output + assert_match "docker tag dhh/app:latest dhh/app:latest", output + assert_match "/usr/bin/env mkdir -p .kamal", output + assert_match "docker ps -q -a --filter label=service=app --filter status=created --filter status=exited --filter status=dead | tail -n +6 | while read container_id; do docker rm $container_id; done", output + assert_match "docker image prune --force --filter label=service=app", output + assert_match "Upgraded proxy on 1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", output + end + end + + test "upgrade rolling" do + Object.any_instance.stubs(:sleep) + + SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("12345678") + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :cut, "-d:", "-f2") + .returns(Kamal::Configuration::PROXY_MINIMUM_VERSION) + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-workers-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") + .returns("running").at_least_once # workers health check + + run_command("upgrade", "--rolling", "-y",).tap do |output| + %w[1.1.1.1 1.1.1.2 1.1.1.3 1.1.1.4].each do |host| + assert_match "Upgrading proxy on #{host}...", output + assert_match "docker container stop traefik ; docker container prune --force --filter label=org.opencontainers.image.title=Traefik && docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik on #{host}", output + assert_match "Upgraded proxy on #{host}", output + end + end + end + + private + def run_command(*command, fixture: :with_proxy) + stdouted { Kamal::Cli::Proxy.start([ *command, "-c", "test/fixtures/deploy_#{fixture}.yml" ]) } + end +end diff --git a/test/cli/prune_test.rb b/test/cli/prune_test.rb index 33723f13..bb4ced51 100644 --- a/test/cli/prune_test.rb +++ b/test/cli/prune_test.rb @@ -18,12 +18,10 @@ class CliPruneTest < CliTestCase test "containers" do run_command("containers").tap do |output| assert_match /docker ps -q -a --filter label=service=app --filter status=created --filter status=exited --filter status=dead | tail -n +6 | while read container_id; do docker rm $container_id; done on 1.1.1.\d/, output - assert_match /docker container prune --force --filter label=service=healthcheck-app on 1.1.1.\d/, output end run_command("containers", "--retain", "10").tap do |output| assert_match /docker ps -q -a --filter label=service=app --filter status=created --filter status=exited --filter status=dead | tail -n +11 | while read container_id; do docker rm $container_id; done on 1.1.1.\d/, output - assert_match /docker container prune --force --filter label=service=healthcheck-app on 1.1.1.\d/, output end assert_raises(RuntimeError, "retain must be at least 1") do diff --git a/test/cli/traefik_test.rb b/test/cli/traefik_test.rb deleted file mode 100644 index 29171150..00000000 --- a/test/cli/traefik_test.rb +++ /dev/null @@ -1,110 +0,0 @@ -require_relative "cli_test_case" - -class CliTraefikTest < CliTestCase - test "boot" do - run_command("boot").tap do |output| - assert_match "docker login", output - assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output - end - end - - test "reboot" do - Kamal::Commands::Registry.any_instance.expects(:login).twice - - run_command("reboot", "-y").tap do |output| - assert_match "docker container stop traefik", output - assert_match "docker container prune --force --filter label=org.opencontainers.image.title=Traefik", output - assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output - end - end - - test "reboot --rolling" do - Object.any_instance.stubs(:sleep) - - run_command("reboot", "--rolling", "-y").tap do |output| - assert_match "Running docker container prune --force --filter label=org.opencontainers.image.title=Traefik on 1.1.1.1", output - end - end - - test "start" do - run_command("start").tap do |output| - assert_match "docker container start traefik", output - end - end - - test "stop" do - run_command("stop").tap do |output| - assert_match "docker container stop traefik", output - end - end - - test "restart" do - Kamal::Cli::Traefik.any_instance.expects(:stop) - Kamal::Cli::Traefik.any_instance.expects(:start) - - run_command("restart") - end - - test "details" do - run_command("details").tap do |output| - assert_match "docker ps --filter name=^traefik$", output - end - end - - test "logs" do - SSHKit::Backend::Abstract.any_instance.stubs(:capture) - .with(:docker, :logs, "traefik", " --tail 100", "--timestamps", "2>&1") - .returns("Log entry") - - run_command("logs").tap do |output| - assert_match "Traefik Host: 1.1.1.1", output - assert_match "Log entry", output - end - end - - test "logs with follow" do - SSHKit::Backend::Abstract.any_instance.stubs(:exec) - .with("ssh -t root@1.1.1.1 -p 22 'docker logs traefik --timestamps --tail 10 --follow 2>&1'") - - assert_match "docker logs traefik --timestamps --tail 10 --follow", run_command("logs", "--follow") - end - - test "logs with follow and grep" do - SSHKit::Backend::Abstract.any_instance.stubs(:exec) - .with("ssh -t root@1.1.1.1 -p 22 'docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hey\"'") - - assert_match "docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hey\"", run_command("logs", "--follow", "--grep", "hey") - end - - test "logs with follow, grep, and grep options" do - SSHKit::Backend::Abstract.any_instance.stubs(:exec) - .with("ssh -t root@1.1.1.1 -p 22 'docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hey\" -C 2'") - - assert_match "docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hey\" -C 2", run_command("logs", "--follow", "--grep", "hey", "--grep-options", "-C 2") - end - - test "remove" do - Kamal::Cli::Traefik.any_instance.expects(:stop) - Kamal::Cli::Traefik.any_instance.expects(:remove_container) - Kamal::Cli::Traefik.any_instance.expects(:remove_image) - - run_command("remove") - end - - test "remove_container" do - run_command("remove_container").tap do |output| - assert_match "docker container prune --force --filter label=org.opencontainers.image.title=Traefik", output - end - end - - test "remove_image" do - run_command("remove_image").tap do |output| - assert_match "docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik", output - end - end - - private - def run_command(*command) - stdouted { Kamal::Cli::Traefik.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } - end -end diff --git a/test/commander_test.rb b/test/commander_test.rb index c2b78b21..54031e80 100644 --- a/test/commander_test.rb +++ b/test/commander_test.rb @@ -136,18 +136,18 @@ class CommanderTest < ActiveSupport::TestCase assert_equal [ "1.1.1.3", "1.1.1.4", "1.1.1.1", "1.1.1.2" ], @kamal.hosts end - test "traefik hosts should observe filtered roles" do - configure_with(:deploy_with_multiple_traefik_roles) + test "proxy hosts should observe filtered roles" do + configure_with(:deploy_with_multiple_proxy_roles) @kamal.specific_roles = [ "web_tokyo" ] - assert_equal [ "1.1.1.3", "1.1.1.4" ], @kamal.traefik_hosts + assert_equal [ "1.1.1.3", "1.1.1.4" ], @kamal.proxy_hosts end - test "traefik hosts should observe filtered hosts" do - configure_with(:deploy_with_multiple_traefik_roles) + test "proxy hosts should observe filtered hosts" do + configure_with(:deploy_with_multiple_proxy_roles) @kamal.specific_hosts = [ "1.1.1.2" ] - assert_equal [ "1.1.1.2" ], @kamal.traefik_hosts + assert_equal [ "1.1.1.2" ], @kamal.proxy_hosts end private diff --git a/test/commands/accessory_test.rb b/test/commands/accessory_test.rb index 23d304da..f3d71ffd 100644 --- a/test/commands/accessory_test.rb +++ b/test/commands/accessory_test.rb @@ -51,15 +51,15 @@ class CommandsAccessoryTest < ActiveSupport::TestCase test "run" do assert_equal \ - "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 --env 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 --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" private.registry/mysql:8.0", new_command(:mysql).run.join(" ") assert_equal \ - "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env 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 --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env SOMETHING=\"else\" --env-file .kamal/apps/app/env/accessories/redis.env --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest", new_command(:redis).run.join(" ") assert_equal \ - "docker run --name custom-busybox --detach --restart unless-stopped --log-opt max-size=\"10m\" --env-file .kamal/env/accessories/custom-busybox.env --label service=\"custom-busybox\" busybox:latest", + "docker run --name custom-busybox --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" busybox:latest", new_command(:busybox).run.join(" ") end @@ -67,7 +67,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } assert_equal \ - "docker run --name custom-busybox --detach --restart unless-stopped --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --env-file .kamal/env/accessories/custom-busybox.env --label service=\"custom-busybox\" busybox:latest", + "docker run --name custom-busybox --detach --restart unless-stopped --network kamal --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" busybox:latest", new_command(:busybox).run.join(" ") end @@ -92,7 +92,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase test "execute in new container" do assert_equal \ - "docker run --rm --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/env/accessories/app-mysql.env private.registry/mysql:8.0 mysql -u root", + "docker run --rm --network kamal --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env private.registry/mysql:8.0 mysql -u root", new_command(:mysql).execute_in_new_container("mysql", "-u", "root").join(" ") end @@ -104,7 +104,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase test "execute in new container over ssh" do new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do - assert_match %r{docker run -it --rm --env 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 --network kamal --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env private.registry/mysql:8.0 mysql -u root}, new_command(:mysql).execute_in_new_container_over_ssh("mysql", "-u", "root") end end @@ -130,12 +130,20 @@ class CommandsAccessoryTest < ActiveSupport::TestCase assert_equal \ "docker logs app-mysql --since 5m --tail 100 --timestamps 2>&1 | grep 'thing' -C 2", new_command(:mysql).logs(since: "5m", lines: 100, grep: "thing", grep_options: "-C 2").join(" ") + + assert_equal \ + "docker logs app-mysql --since 5m --tail 100 2>&1 | grep 'thing' -C 2", + new_command(:mysql).logs(timestamps: false, since: "5m", lines: 100, grep: "thing", grep_options: "-C 2").join(" ") end test "follow logs" do assert_equal \ "ssh -t root@1.1.1.5 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'", new_command(:mysql).follow_logs + + assert_equal \ + "ssh -t root@1.1.1.5 -p 22 'docker logs app-mysql --tail 10 --follow 2>&1'", + new_command(:mysql).follow_logs(timestamps: false) end test "remove container" do diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index e385764e..6704adb6 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -3,9 +3,8 @@ require "test_helper" class CommandsAppTest < ActiveSupport::TestCase setup do setup_test_secrets("secrets" => "RAILS_MASTER_KEY=456") - Kamal::Configuration.any_instance.stubs(:run_id).returns("12345678901234567890123456789012") - @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], env: { "secret" => [ "RAILS_MASTER_KEY" ] }, builder: { "arch" => "amd64" } } + @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: { "web" => [ "1.1.1.1" ], "workers" => [ "1.1.1.2" ] }, env: { "secret" => [ "RAILS_MASTER_KEY" ] }, builder: { "arch" => "amd64" } } end teardown do @@ -14,13 +13,13 @@ class CommandsAppTest < ActiveSupport::TestCase test "run" do assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"(curl -f http://localhost:3000/up || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", + "docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", new_command.run.join(" ") end test "run with hostname" do assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 --hostname myhost -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"(curl -f http://localhost:3000/up || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", + "docker run --detach --restart unless-stopped --name app-web-999 --network kamal --hostname myhost -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", new_command.run(hostname: "myhost").join(" ") end @@ -28,38 +27,14 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:volumes] = [ "/local/path:/container/path" ] assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"(curl -f http://localhost:3000/up || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --volume /local/path:/container/path --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", - new_command.run.join(" ") - end - - test "run with custom healthcheck path" do - @config[:healthcheck] = { "path" => "/healthz" } - - assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"(curl -f http://localhost:3000/healthz || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", - new_command.run.join(" ") - end - - test "run with custom healthcheck command" do - @config[:healthcheck] = { "cmd" => "/bin/up" } - - assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"(/bin/up) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", - new_command.run.join(" ") - end - - test "run with role-specific healthcheck options" do - @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "healthcheck" => { "cmd" => "/bin/healthy" } } } - - assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"(/bin/healthy) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", + "docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --volume /local/path:/container/path --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", new_command.run.join(" ") end test "run with custom options" do @config[:servers] = { "web" => [ "1.1.1.1" ], "jobs" => { "hosts" => [ "1.1.1.2" ], "cmd" => "bin/jobs", "options" => { "mount" => "somewhere", "cap-add" => true } } } assert_equal \ - "docker run --detach --restart unless-stopped --name app-jobs-999 -e KAMAL_CONTAINER_NAME=\"app-jobs-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-jobs.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"jobs\" --label destination --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs", + "docker run --detach --restart unless-stopped --name app-jobs-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-jobs-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/apps/app/env/roles/jobs.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"jobs\" --label destination --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs", new_command(role: "jobs", host: "1.1.1.2").run.join(" ") end @@ -67,7 +42,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"(curl -f http://localhost:3000/up || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", + "docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/apps/app/env/roles/web.env --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", new_command.run.join(" ") end @@ -76,7 +51,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "logging" => { "driver" => "local", "options" => { "max-size" => "100m" } } } } assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --health-cmd \"(curl -f http://localhost:3000/up || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", + "docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/apps/app/env/roles/web.env --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", new_command.run.join(" ") end @@ -85,7 +60,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env 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 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env ENV1=\"value1\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", new_command.run.join(" ") end @@ -108,11 +83,15 @@ class CommandsAppTest < ActiveSupport::TestCase new_command.stop.join(" ") end - test "stop with custom stop wait time" do - @config[:stop_wait_time] = 30 + test "stop with custom drain timeout" do + @config[:drain_timeout] = 20 assert_equal \ - "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop -t 30", + "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop", new_command.stop.join(" ") + + assert_equal \ + "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=workers --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=workers --filter status=running --filter status=restarting' | head -1 | xargs docker stop -t 20", + new_command(role: "workers").stop.join(" ") end test "stop with version" do @@ -134,52 +113,65 @@ class CommandsAppTest < ActiveSupport::TestCase new_command.info.join(" ") end + test "deploy" do + assert_equal \ + "docker exec kamal-proxy kamal-proxy deploy app-web --target \"172.1.0.2:80\" --deploy-timeout \"30s\" --drain-timeout \"30s\" --buffer-requests --buffer-responses --log-request-header \"Cache-Control\" --log-request-header \"Last-Modified\" --log-request-header \"User-Agent\"", + new_command.deploy(target: "172.1.0.2").join(" ") + end + + test "remove" do + assert_equal \ + "docker exec kamal-proxy kamal-proxy remove app-web --target \"172.1.0.2:80\"", + new_command.remove(target: "172.1.0.2").join(" ") + end + + test "logs" do assert_equal \ - "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1", + "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1", new_command.logs.join(" ") end test "logs with since" do assert_equal \ - "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --since 5m 2>&1", + "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m 2>&1", new_command.logs(since: "5m").join(" ") end test "logs with lines" do assert_equal \ - "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --tail 100 2>&1", + "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 100 2>&1", new_command.logs(lines: "100").join(" ") end test "logs with since and lines" do assert_equal \ - "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --since 5m --tail 100 2>&1", + "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m --tail 100 2>&1", new_command.logs(since: "5m", lines: "100").join(" ") end test "logs with grep" do assert_equal \ - "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1 | grep 'my-id'", + "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'my-id'", new_command.logs(grep: "my-id").join(" ") end test "logs with grep and grep options" do assert_equal \ - "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs 2>&1 | grep 'my-id' -C 2", + "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'my-id' -C 2", new_command.logs(grep: "my-id", grep_options: "-C 2").join(" ") end test "logs with since, grep and grep options" do assert_equal \ - "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --since 5m 2>&1 | grep 'my-id' -C 2", + "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m 2>&1 | grep 'my-id' -C 2", new_command.logs(since: "5m", grep: "my-id", grep_options: "-C 2").join(" ") end test "logs with since and grep" do assert_equal \ - "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --since 5m 2>&1 | grep 'my-id'", + "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m 2>&1 | grep 'my-id'", new_command.logs(since: "5m", grep: "my-id").join(" ") end @@ -199,18 +191,22 @@ class CommandsAppTest < ActiveSupport::TestCase assert_equal \ "ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1 | grep \"Completed\"'", new_command.follow_logs(host: "app-1", lines: 123, grep: "Completed") + + assert_equal \ + "ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --tail 123 --follow 2>&1 | grep \"Completed\"'", + new_command.follow_logs(host: "app-1", timestamps: false, lines: 123, grep: "Completed") end test "execute in new container" do assert_equal \ - "docker run --rm --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails db:setup", + "docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env dhh/app:999 bin/rails db:setup", new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") end test "execute in new container with env" do assert_equal \ - "docker run --rm --env-file .kamal/env/roles/app-web.env --env foo=\"bar\" dhh/app:999 bin/rails db:setup", + "docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --env foo=\"bar\" dhh/app:999 bin/rails db:setup", new_command.execute_in_new_container("bin/rails", "db:setup", env: { "foo" => "bar" }).join(" ") end @@ -219,14 +215,14 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } 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 --network kamal --env ENV1=\"value1\" --env-file .kamal/apps/app/env/roles/web.env dhh/app:999 bin/rails db:setup", new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") end test "execute in new container with custom options" do @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } assert_equal \ - "docker run --rm --env-file .kamal/env/roles/app-web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails db:setup", + "docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails db:setup", new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") end @@ -243,7 +239,7 @@ class CommandsAppTest < ActiveSupport::TestCase end test "execute in new container over ssh" do - assert_match %r{docker run -it --rm --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails c}, + assert_match %r{docker run -it --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env dhh/app:999 bin/rails c}, new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) end @@ -251,13 +247,13 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:servers] = [ { "1.1.1.1" => "tag1" } ] @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } - assert_equal "ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --env 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 --network kamal --env ENV1=\"value1\" --env-file .kamal/apps/app/env/roles/web.env dhh/app:999 bin/rails c'", new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) end test "execute in new container with custom options over ssh" do @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } - assert_match %r{docker run -it --rm --env-file .kamal/env/roles/app-web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails c}, + assert_match %r{docker run -it --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails c}, new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) end @@ -412,48 +408,34 @@ class CommandsAppTest < ActiveSupport::TestCase new_command.tag_latest_image.join(" ") end - test "cord" do - assert_equal "docker inspect -f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}' app-web-123 | awk '$2 == \"/tmp/kamal-cord\" {print $1}'", new_command.cord(version: 123).join(" ") - end - - test "tie cord" do - assert_equal "mkdir -p . ; touch cordfile", new_command.tie_cord("cordfile").join(" ") - assert_equal "mkdir -p corddir ; touch corddir/cordfile", new_command.tie_cord("corddir/cordfile").join(" ") - assert_equal "mkdir -p /corddir ; touch /corddir/cordfile", new_command.tie_cord("/corddir/cordfile").join(" ") - end - - test "cut cord" do - assert_equal "rm -r corddir", new_command.cut_cord("corddir").join(" ") - end - test "extract assets" do assert_equal [ - :mkdir, "-p", ".kamal/assets/extracted/app-web-999", "&&", + :mkdir, "-p", ".kamal/apps/app/assets/extracted/web-999", "&&", :docker, :stop, "-t 1", "app-web-assets", "2> /dev/null", "|| true", "&&", :docker, :run, "--name", "app-web-assets", "--detach", "--rm", "--entrypoint", "sleep", "dhh/app:999", "1000000", "&&", - :docker, :cp, "-L", "app-web-assets:/public/assets/.", ".kamal/assets/extracted/app-web-999", "&&", + :docker, :cp, "-L", "app-web-assets:/public/assets/.", ".kamal/apps/app/assets/extracted/web-999", "&&", :docker, :stop, "-t 1", "app-web-assets" ], new_command(asset_path: "/public/assets").extract_assets end test "sync asset volumes" do assert_equal [ - :mkdir, "-p", ".kamal/assets/volumes/app-web-999", ";", - :cp, "-rnT", ".kamal/assets/extracted/app-web-999", ".kamal/assets/volumes/app-web-999" + :mkdir, "-p", ".kamal/apps/app/assets/volumes/web-999", ";", + :cp, "-rnT", ".kamal/apps/app/assets/extracted/web-999", ".kamal/apps/app/assets/volumes/web-999" ], new_command(asset_path: "/public/assets").sync_asset_volumes assert_equal [ - :mkdir, "-p", ".kamal/assets/volumes/app-web-999", ";", - :cp, "-rnT", ".kamal/assets/extracted/app-web-999", ".kamal/assets/volumes/app-web-999", ";", - :cp, "-rnT", ".kamal/assets/extracted/app-web-999", ".kamal/assets/volumes/app-web-998", "|| true", ";", - :cp, "-rnT", ".kamal/assets/extracted/app-web-998", ".kamal/assets/volumes/app-web-999", "|| true" + :mkdir, "-p", ".kamal/apps/app/assets/volumes/web-999", ";", + :cp, "-rnT", ".kamal/apps/app/assets/extracted/web-999", ".kamal/apps/app/assets/volumes/web-999", ";", + :cp, "-rnT", ".kamal/apps/app/assets/extracted/web-999", ".kamal/apps/app/assets/volumes/web-998", "|| true", ";", + :cp, "-rnT", ".kamal/apps/app/assets/extracted/web-998", ".kamal/apps/app/assets/volumes/web-999", "|| true" ], new_command(asset_path: "/public/assets").sync_asset_volumes(old_version: 998) end test "clean up assets" do assert_equal [ - :find, ".kamal/assets/extracted", "-maxdepth 1", "-name", "'app-web-*'", "!", "-name", "app-web-999", "-exec rm -rf \"{}\" +", ";", - :find, ".kamal/assets/volumes", "-maxdepth 1", "-name", "'app-web-*'", "!", "-name", "app-web-999", "-exec rm -rf \"{}\" +" + :find, ".kamal/apps/app/assets/extracted", "-maxdepth 1", "-name", "'web-*'", "!", "-name", "web-999", "-exec rm -rf \"{}\" +", ";", + :find, ".kamal/apps/app/assets/volumes", "-maxdepth 1", "-name", "'web-*'", "!", "-name", "web-999", "-exec rm -rf \"{}\" +" ], new_command(asset_path: "/public/assets").clean_up_assets end diff --git a/test/commands/hook_test.rb b/test/commands/hook_test.rb index f6234d6a..fe1cdc3f 100644 --- a/test/commands/hook_test.rb +++ b/test/commands/hook_test.rb @@ -8,7 +8,7 @@ class CommandsHookTest < ActiveSupport::TestCase @config = { 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" } } + builder: { "arch" => "amd64" } } @performer = Kamal::Git.email.presence || `whoami`.chomp @@ -16,41 +16,34 @@ class CommandsHookTest < ActiveSupport::TestCase end test "run" 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" } } - ], new_command.run("foo") + assert_equal [ ".kamal/hooks/foo" ], new_command.run("foo") + end + + test "env" do + assert_equal ({ + "KAMAL_RECORDED_AT" => @recorded_at, + "KAMAL_PERFORMER" => @performer, + "KAMAL_VERSION" => "123", + "KAMAL_SERVICE_VERSION" => "app@123", + "KAMAL_SERVICE" => "app" + }), new_command.env end test "run with custom hooks_path" do - assert_equal [ - "custom/hooks/path/foo", - { env: { - "KAMAL_RECORDED_AT" => @recorded_at, - "KAMAL_PERFORMER" => @performer, - "KAMAL_VERSION" => "123", - "KAMAL_SERVICE_VERSION" => "app@123", - "KAMAL_SERVICE" => "app" } } - ], new_command(hooks_path: "custom/hooks/path").run("foo") + assert_equal [ "custom/hooks/path/foo" ], new_command(hooks_path: "custom/hooks/path").run("foo") end - test "hook with secrets" do + test "env with secrets" do with_test_secrets("secrets" => "DB_PASSWORD=secret") do - assert_equal [ - ".kamal/hooks/foo", - { env: { + assert_equal ( + { "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) + "DB_PASSWORD" => "secret" } + ), new_command.env(secrets: true) end end diff --git a/test/commands/lock_test.rb b/test/commands/lock_test.rb index 02871922..0ad0ba46 100644 --- a/test/commands/lock_test.rb +++ b/test/commands/lock_test.rb @@ -4,25 +4,25 @@ class CommandsLockTest < ActiveSupport::TestCase setup do @config = { 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" } } + builder: { "arch" => "amd64" } } end test "status" do assert_equal \ - "stat .kamal/locks/app-production > /dev/null && cat .kamal/locks/app-production/details | base64 -d", + "stat .kamal/lock-app-production > /dev/null && cat .kamal/lock-app-production/details | base64 -d", new_command.status.join(" ") end test "acquire" do assert_match \ - %r{mkdir \.kamal/locks/app-production && echo ".*" > \.kamal/locks/app-production/details}m, + %r{mkdir \.kamal/lock-app-production && echo ".*" > \.kamal/lock-app-production/details}m, new_command.acquire("Hello", "123").join(" ") end test "release" do assert_match \ - "rm .kamal/locks/app-production/details && rm -r .kamal/locks/app-production", + "rm .kamal/lock-app-production/details && rm -r .kamal/lock-app-production", new_command.release.join(" ") end diff --git a/test/commands/proxy_test.rb b/test/commands/proxy_test.rb new file mode 100644 index 00000000..e8a3f252 --- /dev/null +++ b/test/commands/proxy_test.rb @@ -0,0 +1,126 @@ +require "test_helper" + +class CommandsProxyTest < ActiveSupport::TestCase + setup do + @config = { + service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: { "arch" => "amd64" } + } + + ENV["EXAMPLE_API_KEY"] = "456" + end + + teardown do + ENV.delete("EXAMPLE_API_KEY") + end + + test "run" do + assert_equal \ + "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --log-opt max-size=\"10m\" basecamp/kamal-proxy:#{Kamal::Configuration::PROXY_MINIMUM_VERSION}", + new_command.run.join(" ") + end + + test "run with ports configured" do + assert_equal \ + "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --log-opt max-size=\"10m\" basecamp/kamal-proxy:#{Kamal::Configuration::PROXY_MINIMUM_VERSION}", + new_command.run.join(" ") + end + + test "run without configuration" do + @config.delete(:proxy) + + assert_equal \ + "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --log-opt max-size=\"10m\" basecamp/kamal-proxy:#{Kamal::Configuration::PROXY_MINIMUM_VERSION}", + new_command.run.join(" ") + end + + test "run with logging config" do + @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } + + assert_equal \ + "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --publish 80:80 --publish 443:443 --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" basecamp/kamal-proxy:#{Kamal::Configuration::PROXY_MINIMUM_VERSION}", + new_command.run.join(" ") + end + + test "proxy start" do + assert_equal \ + "docker container start kamal-proxy", + new_command.start.join(" ") + end + + test "proxy stop" do + assert_equal \ + "docker container stop kamal-proxy", + new_command.stop.join(" ") + end + + test "proxy info" do + assert_equal \ + "docker ps --filter name=^kamal-proxy$", + new_command.info.join(" ") + end + + test "proxy logs" do + assert_equal \ + "docker logs kamal-proxy --timestamps 2>&1", + new_command.logs.join(" ") + end + + test "proxy logs since 2h" do + assert_equal \ + "docker logs kamal-proxy --since 2h --timestamps 2>&1", + new_command.logs(since: "2h").join(" ") + end + + test "proxy logs last 10 lines" do + assert_equal \ + "docker logs kamal-proxy --tail 10 --timestamps 2>&1", + new_command.logs(lines: 10).join(" ") + end + + test "proxy logs without timestamps" do + assert_equal \ + "docker logs kamal-proxy 2>&1", + new_command.logs(timestamps: false).join(" ") + end + + test "proxy logs with grep hello!" do + assert_equal \ + "docker logs kamal-proxy --timestamps 2>&1 | grep 'hello!'", + new_command.logs(grep: "hello!").join(" ") + end + + test "proxy remove container" do + assert_equal \ + "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy", + new_command.remove_container.join(" ") + end + + test "proxy remove image" do + assert_equal \ + "docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy", + new_command.remove_image.join(" ") + end + + test "proxy follow logs" do + assert_equal \ + "ssh -t root@1.1.1.1 -p 22 'docker logs kamal-proxy --timestamps --tail 10 --follow 2>&1'", + new_command.follow_logs(host: @config[:servers].first) + end + + test "proxy follow logs with grep hello!" do + assert_equal \ + "ssh -t root@1.1.1.1 -p 22 'docker logs kamal-proxy --timestamps --tail 10 --follow 2>&1 | grep \"hello!\"'", + new_command.follow_logs(host: @config[:servers].first, grep: "hello!") + end + + test "version" do + assert_equal \ + "docker inspect kamal-proxy --format '{{.Config.Image}}' | cut -d: -f2", + new_command.version.join(" ") + end + + private + def new_command + Kamal::Commands::Proxy.new(Kamal::Configuration.new(@config, version: "123")) + end +end diff --git a/test/commands/prune_test.rb b/test/commands/prune_test.rb index 430a13db..50e852bc 100644 --- a/test/commands/prune_test.rb +++ b/test/commands/prune_test.rb @@ -4,7 +4,7 @@ class CommandsPruneTest < ActiveSupport::TestCase setup do @config = { 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" } } + builder: { "arch" => "amd64" } } end @@ -30,12 +30,6 @@ class CommandsPruneTest < ActiveSupport::TestCase new_command.app_containers(retain: 3).join(" ") end - test "healthcheck containers" do - assert_equal \ - "docker container prune --force --filter label=service=healthcheck-app", - new_command.healthcheck_containers.join(" ") - end - private def new_command Kamal::Commands::Prune.new(Kamal::Configuration.new(@config, version: "123")) diff --git a/test/commands/server_test.rb b/test/commands/server_test.rb index 8c465fd9..648821b4 100644 --- a/test/commands/server_test.rb +++ b/test/commands/server_test.rb @@ -4,7 +4,7 @@ class CommandsServerTest < ActiveSupport::TestCase setup do @config = { 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" } } + builder: { "arch" => "amd64" } } end @@ -12,10 +12,6 @@ class CommandsServerTest < ActiveSupport::TestCase assert_equal "mkdir -p .kamal", new_command.ensure_run_directory.join(" ") 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 def new_command(extra_config = {}) Kamal::Commands::Server.new(Kamal::Configuration.new(@config.merge(extra_config))) diff --git a/test/commands/traefik_test.rb b/test/commands/traefik_test.rb deleted file mode 100644 index b13e3700..00000000 --- a/test/commands/traefik_test.rb +++ /dev/null @@ -1,195 +0,0 @@ -require "test_helper" - -class CommandsTraefikTest < ActiveSupport::TestCase - setup do - @image = "traefik:test" - - @config = { - service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: { "arch" => "amd64" }, - 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") - end - - teardown do - teardown_test_secrets - end - - test "run" do - assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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(" ") - - @config[:traefik]["host_port"] = "8080" - assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 8080:80 --volume /var/run/docker.sock:/var/run/docker.sock --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(" ") - - @config[:traefik]["publish"] = false - assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --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(" ") - end - - test "run with ports configured" do - assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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(" ") - - @config[:traefik]["options"] = { "publish" => %w[9000:9000 9001:9001] } - assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" --publish \"9000:9000\" --publish \"9001:9001\" #{@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(" ") - end - - test "run with volumes configured" do - assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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(" ") - - @config[:traefik]["options"] = { "volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json] } - assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" #{@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(" ") - end - - test "run with several options configured" do - assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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(" ") - - @config[:traefik]["options"] = { "volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json], "publish" => %w[8080:8080], "memory" => "512m" } - assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" --publish \"8080:8080\" --memory \"512m\" #{@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(" ") - end - - test "run with labels configured" do - assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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(" ") - - @config[:traefik]["labels"] = { "traefik.http.routers.dashboard.service" => "api@internal", "traefik.http.routers.dashboard.middlewares" => "auth" } - assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" --label traefik.http.routers.dashboard.service=\"api@internal\" --label traefik.http.routers.dashboard.middlewares=\"auth\" #{@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(" ") - end - - test "run with env configured" do - assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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(" ") - - @config[:traefik]["env"] = { "EXAMPLE_API_KEY" => "456" } - 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\"", - new_command.run.join(" ") - end - - test "run without configuration" do - @config.delete(:traefik) - - assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", - new_command.run.join(" ") - end - - test "run with logging config" do - @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } - - assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --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(" ") - end - - test "run with default args overriden" do - @config[:traefik]["args"]["log.level"] = "ERROR" - - assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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=\"ERROR\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", - new_command.run.join(" ") - end - - test "run with args array" do - @config[:traefik]["args"] = { "entrypoints.web.forwardedheaders.trustedips" => %w[ 127.0.0.1 127.0.0.2 ] } - assert_equal "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\" traefik:test --providers.docker --log.level=\"DEBUG\" --entrypoints.web.forwardedheaders.trustedips=\"127.0.0.1\" --entrypoints.web.forwardedheaders.trustedips=\"127.0.0.2\"", new_command.run.join(" ") - end - - test "traefik start" do - assert_equal \ - "docker container start traefik", - new_command.start.join(" ") - end - - test "traefik stop" do - assert_equal \ - "docker container stop traefik", - new_command.stop.join(" ") - end - - test "traefik info" do - assert_equal \ - "docker ps --filter name=^traefik$", - new_command.info.join(" ") - end - - test "traefik logs" do - assert_equal \ - "docker logs traefik --timestamps 2>&1", - new_command.logs.join(" ") - end - - test "traefik logs since 2h" do - assert_equal \ - "docker logs traefik --since 2h --timestamps 2>&1", - new_command.logs(since: "2h").join(" ") - end - - test "traefik logs last 10 lines" do - assert_equal \ - "docker logs traefik --tail 10 --timestamps 2>&1", - new_command.logs(lines: 10).join(" ") - end - - test "traefik logs with grep hello!" do - assert_equal \ - "docker logs traefik --timestamps 2>&1 | grep 'hello!'", - new_command.logs(grep: "hello!").join(" ") - end - - test "traefik logs with grep hello! and grep options" do - assert_equal \ - "docker logs traefik --timestamps 2>&1 | grep 'hello!' -C 2", - new_command.logs(grep: "hello!", grep_options: "-C 2").join(" ") - end - - test "traefik remove container" do - assert_equal \ - "docker container prune --force --filter label=org.opencontainers.image.title=Traefik", - new_command.remove_container.join(" ") - end - - test "traefik remove image" do - assert_equal \ - "docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik", - new_command.remove_image.join(" ") - end - - test "traefik follow logs" do - assert_equal \ - "ssh -t root@1.1.1.1 -p 22 'docker logs traefik --timestamps --tail 10 --follow 2>&1'", - new_command.follow_logs(host: @config[:servers].first) - end - - test "traefik follow logs with grep hello!" do - assert_equal \ - "ssh -t root@1.1.1.1 -p 22 'docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hello!\"'", - new_command.follow_logs(host: @config[:servers].first, grep: "hello!") - end - - private - def new_command - Kamal::Commands::Traefik.new(Kamal::Configuration.new(@config, version: "123")) - end -end diff --git a/test/configuration/accessory_test.rb b/test/configuration/accessory_test.rb index acfe991f..2615dab6 100644 --- a/test/configuration/accessory_test.rb +++ b/test/configuration/accessory_test.rb @@ -119,9 +119,9 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase with_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123") do config = Kamal::Configuration.new(@deploy) - assert_equal [ "--env", "MYSQL_ROOT_HOST=\"%\"", "--env-file", ".kamal/env/accessories/app-mysql.env" ], config.accessory(:mysql).env_args.map(&:to_s) + assert_equal [ "--env", "MYSQL_ROOT_HOST=\"%\"", "--env-file", ".kamal/apps/app/env/accessories/mysql.env" ], config.accessory(:mysql).env_args.map(&:to_s) assert_equal "MYSQL_ROOT_PASSWORD=secret123\n", config.accessory(:mysql).secrets_io.string - assert_equal [ "--env", "SOMETHING=\"else\"", "--env-file", ".kamal/env/accessories/app-redis.env" ], @config.accessory(:redis).env_args + assert_equal [ "--env", "SOMETHING=\"else\"", "--env-file", ".kamal/apps/app/env/accessories/redis.env" ], @config.accessory(:redis).env_args assert_equal "\n", config.accessory(:redis).secrets_io.string end end diff --git a/test/configuration/proxy_test.rb b/test/configuration/proxy_test.rb new file mode 100644 index 00000000..3aa3f85e --- /dev/null +++ b/test/configuration/proxy_test.rb @@ -0,0 +1,25 @@ +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 diff --git a/test/configuration/role_test.rb b/test/configuration/role_test.rb index c0b643bf..c7a94de0 100644 --- a/test/configuration/role_test.rb +++ b/test/configuration/role_test.rb @@ -39,7 +39,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase end 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" ], config.role(:web).label_args end test "custom labels" do @@ -53,24 +53,19 @@ class ConfigurationRoleTest < ActiveSupport::TestCase assert_equal "70", Kamal::Configuration.new(@deploy_with_roles).role(:workers).labels["my.custom.label"] end - test "overwriting default traefik label" do - @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"] - end - - test "default traefik label on non-web role" do + test "default proxy label on non-web role" do config = Kamal::Configuration.new(@deploy_with_roles.tap { |c| - c[:servers]["beta"] = { "traefik" => true, "hosts" => [ "1.1.1.5" ] } + c[:servers]["beta"] = { "proxy" => true, "hosts" => [ "1.1.1.5" ] } }) - assert_equal [ "--label", "service=\"app\"", "--label", "role=\"beta\"", "--label", "destination", "--label", "traefik.http.services.app-beta.loadbalancer.server.scheme=\"http\"", "--label", "traefik.http.routers.app-beta.rule=\"PathPrefix(\\`/\\`)\"", "--label", "traefik.http.routers.app-beta.priority=\"2\"", "--label", "traefik.http.middlewares.app-beta-retry.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-beta-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app-beta.middlewares=\"app-beta-retry@docker\"" ], config.role(:beta).label_args + assert_equal [ "--label", "service=\"app\"", "--label", "role=\"beta\"", "--label", "destination" ], config.role(:beta).label_args end 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 \ - [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/env/roles/app-workers.env" ], + [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/apps/app/env/roles/workers.env" ], config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) assert_equal \ @@ -89,7 +84,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase test "env args" do assert_equal \ - [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/env/roles/app-workers.env" ], + [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/apps/app/env/roles/workers.env" ], config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) assert_equal \ @@ -119,7 +114,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase } assert_equal \ - [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/env/roles/app-workers.env" ], + [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/apps/app/env/roles/workers.env" ], config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) assert_equal \ @@ -141,7 +136,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase } assert_equal \ - [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/env/roles/app-workers.env" ], + [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/apps/app/env/roles/workers.env" ], config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) assert_equal \ @@ -162,7 +157,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase } assert_equal \ - [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/env/roles/app-workers.env" ], + [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/apps/app/env/roles/workers.env" ], config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) assert_equal \ @@ -189,7 +184,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase } assert_equal \ - [ "--env", "REDIS_URL=\"redis://c/d\"", "--env-file", ".kamal/env/roles/app-workers.env" ], + [ "--env", "REDIS_URL=\"redis://c/d\"", "--env-file", ".kamal/apps/app/env/roles/workers.env" ], config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) assert_equal \ @@ -198,26 +193,6 @@ class ConfigurationRoleTest < ActiveSupport::TestCase end end - test "uses cord" do - assert config_with_roles.role(:web).uses_cord? - assert_not config_with_roles.role(:workers).uses_cord? - end - - 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 - end - - test "cord volume" do - 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_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] - end - - test "cord container file" do - assert_equal "/tmp/kamal-cord/cord", config_with_roles.role(:web).cord_container_file - end - test "asset path and volume args" do ENV["VERSION"] = "12345" assert_nil config_with_roles.role(:web).asset_volume_args @@ -232,7 +207,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase }) assert_equal "foo", config_with_assets.role(:web).asset_path assert_equal "foo", config_with_assets.role(:workers).asset_path - assert_equal [ "--volume", "$(pwd)/.kamal/assets/volumes/app-web-12345:foo" ], config_with_assets.role(:web).asset_volume_args + assert_equal [ "--volume", "$(pwd)/.kamal/apps/app/assets/volumes/web-12345:foo" ], config_with_assets.role(:web).asset_volume_args assert_nil config_with_assets.role(:workers).asset_volume_args assert config_with_assets.role(:web).assets? assert_not config_with_assets.role(:workers).assets? @@ -242,7 +217,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase }) assert_equal "bar", config_with_assets.role(:web).asset_path assert_nil config_with_assets.role(:workers).asset_path - assert_equal [ "--volume", "$(pwd)/.kamal/assets/volumes/app-web-12345:bar" ], config_with_assets.role(:web).asset_volume_args + assert_equal [ "--volume", "$(pwd)/.kamal/apps/app/assets/volumes/web-12345:bar" ], config_with_assets.role(:web).asset_volume_args assert_nil config_with_assets.role(:workers).asset_volume_args assert config_with_assets.role(:web).assets? assert_not config_with_assets.role(:workers).assets? @@ -253,20 +228,36 @@ class ConfigurationRoleTest < ActiveSupport::TestCase test "asset extracted path" do 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-workers-12345", config_with_roles.role(:workers).asset_extracted_path + assert_equal ".kamal/apps/app/assets/extracted/web-12345", config_with_roles.role(:web).asset_extracted_directory + assert_equal ".kamal/apps/app/assets/extracted/workers-12345", config_with_roles.role(:workers).asset_extracted_directory ensure ENV.delete("VERSION") end test "asset volume path" do 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-workers-12345", config_with_roles.role(:workers).asset_volume_path + assert_equal ".kamal/apps/app/assets/volumes/web-12345", config_with_roles.role(:web).asset_volume_directory + assert_equal ".kamal/apps/app/assets/volumes/workers-12345", config_with_roles.role(:workers).asset_volume_directory ensure ENV.delete("VERSION") end + test "stop args with proxy" do + assert_equal [], config_with_roles.role(:web).stop_args + end + + test "stop args with no proxy" do + assert_equal [ "-t", 30 ], config_with_roles.role(:workers).stop_args + end + + test "role specific proxy config" do + @deploy_with_roles[:proxy] = { "response_timeout" => 15 } + @deploy_with_roles[:servers]["workers"]["proxy"] = { "response_timeout" => 18 } + + assert_equal "15s", config_with_roles.role(:web).proxy.deploy_options[:"target-timeout"] + assert_equal "18s", config_with_roles.role(:workers).proxy.deploy_options[:"target-timeout"] + end + private def config Kamal::Configuration.new(@deploy) diff --git a/test/configuration/validation_test.rb b/test/configuration/validation_test.rb index ac409e84..179aacfe 100644 --- a/test/configuration/validation_test.rb +++ b/test/configuration/validation_test.rb @@ -14,7 +14,7 @@ class ConfigurationValidationTest < ActiveSupport::TestCase assert_error "#{key}: should be a boolean", **{ key => "foo" } end - [ :stop_wait_time, :retain_containers, :readiness_delay ].each do |key| + [ :deploy_timeout, :drain_timeout, :retain_containers, :readiness_delay ].each do |key| assert_error "#{key}: should be an integer", **{ key => "foo" } end @@ -22,7 +22,7 @@ class ConfigurationValidationTest < ActiveSupport::TestCase assert_error "servers: should be an array or a hash", servers: "foo" - [ :labels, :registry, :accessories, :env, :ssh, :sshkit, :builder, :traefik, :boot, :healthcheck, :logging ].each do |key| + [ :labels, :registry, :accessories, :env, :ssh, :sshkit, :builder, :proxy, :boot, :logging ].each do |key| assert_error "#{key}: should be a hash", **{ key =>[] } end end diff --git a/test/configuration_test.rb b/test/configuration_test.rb index aa78dda9..814aa26e 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -77,22 +77,22 @@ class ConfigurationTest < ActiveSupport::TestCase assert_equal "1.1.1.1", @config_with_roles.primary_host end - test "traefik hosts" do - assert_equal [ "1.1.1.1", "1.1.1.2" ], @config_with_roles.traefik_hosts + test "proxy hosts" do + assert_equal [ "1.1.1.1", "1.1.1.2" ], @config_with_roles.proxy_hosts - @deploy_with_roles[:servers]["workers"]["traefik"] = true + @deploy_with_roles[:servers]["workers"]["proxy"] = true config = Kamal::Configuration.new(@deploy_with_roles) - assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3" ], config.traefik_hosts + assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3" ], config.proxy_hosts end - test "filtered traefik hosts" do - assert_equal [ "1.1.1.1", "1.1.1.2" ], @config_with_roles.traefik_hosts + test "filtered proxy hosts" do + assert_equal [ "1.1.1.1", "1.1.1.2" ], @config_with_roles.proxy_hosts - @deploy_with_roles[:servers]["workers"]["traefik"] = true + @deploy_with_roles[:servers]["workers"]["proxy"] = true config = Kamal::Configuration.new(@deploy_with_roles) - assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3" ], config.traefik_hosts + assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3" ], config.proxy_hosts end test "version no git repo" do @@ -157,10 +157,6 @@ class ConfigurationTest < ActiveSupport::TestCase assert_equal "app-missing", @config.service_with_version end - test "healthcheck service" do - assert_equal "healthcheck-app", @config.healthcheck_service - end - test "hosts required for all roles" do # Empty server list for implied web role assert_raises(Kamal::ConfigurationError) do @@ -269,8 +265,7 @@ class ConfigurationTest < ActiveSupport::TestCase sshkit: {}, volume_args: [ "--volume", "/local/path:/container/path" ], builder: { "arch" => "amd64" }, - 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 } } + logging: [ "--log-opt", "max-size=\"10m\"" ] } assert_equal expected_config, @config.to_h end @@ -294,22 +289,6 @@ class ConfigurationTest < ActiveSupport::TestCase test "run directory" do config = Kamal::Configuration.new(@deploy) assert_equal ".kamal", config.run_directory - - config = Kamal::Configuration.new(@deploy.merge!(run_directory: "/root/kamal")) - assert_equal "/root/kamal", config.run_directory - end - - test "run directory as docker volume" do - config = Kamal::Configuration.new(@deploy) - 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 - - test "run id" do - SecureRandom.expects(:hex).with(16).returns("09876543211234567890098765432112") - assert_equal "09876543211234567890098765432112", @config.run_id end test "asset path" do @@ -328,7 +307,7 @@ class ConfigurationTest < ActiveSupport::TestCase assert_equal "alternate_web", config.primary_role.name assert_equal "1.1.1.4", config.primary_host assert config.role(:alternate_web).primary? - assert config.role(:alternate_web).running_traefik? + assert config.role(:alternate_web).running_proxy? end test "primary role missing" do @@ -350,7 +329,52 @@ class ConfigurationTest < ActiveSupport::TestCase dest_config_file = Pathname.new(File.expand_path("fixtures/deploy_with_extensions.yml", __dir__)) config = Kamal::Configuration.create_from config_file: dest_config_file - assert_equal config.role(:web_tokyo).running_traefik?, true - assert_equal config.role(:web_chicago).running_traefik?, true + assert_equal config.role(:web_tokyo).running_proxy?, true + assert_equal config.role(:web_chicago).running_proxy?, true + end + + test "traefik hooks raise error" do + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + FileUtils.mkdir_p ".kamal/hooks" + FileUtils.touch ".kamal/hooks/post-traefik-reboot" + FileUtils.touch ".kamal/hooks/pre-traefik-reboot" + exception = assert_raises(Kamal::ConfigurationError) do + Kamal::Configuration.new(@deploy) + end + assert_equal "Found pre-traefik-reboot, post-traefik-reboot, these should be renamed to (pre|post)-proxy-reboot", exception.message + end + end + end + + test "proxy ssl roles with no host" do + @deploy_with_roles[:servers]["workers"]["proxy"] = { "ssl" => true } + + exception = assert_raises(Kamal::ConfigurationError) do + Kamal::Configuration.new(@deploy_with_roles) + end + + assert_equal "servers/workers/proxy: Must set a host to enable automatic SSL", exception.message + end + + test "proxy ssl roles with multiple servers" do + @deploy_with_roles[:servers]["workers"]["proxy"] = { "ssl" => true, "host" => "foo.example.com" } + + exception = assert_raises(Kamal::ConfigurationError) do + Kamal::Configuration.new(@deploy_with_roles) + end + + assert_equal "SSL is only supported on a single server, found 2 servers for role workers", exception.message + end + + test "two proxy ssl roles with same host" do + @deploy_with_roles[:servers]["web"] = { "hosts" => [ "1.1.1.1" ], "proxy" => { "ssl" => true, "host" => "foo.example.com" } } + @deploy_with_roles[:servers]["workers"] = { "hosts" => [ "1.1.1.1" ], "proxy" => { "ssl" => true, "host" => "foo.example.com" } } + + exception = assert_raises(Kamal::ConfigurationError) do + Kamal::Configuration.new(@deploy_with_roles) + end + + assert_equal "Different roles can't share the same host for SSL: foo.example.com", exception.message end end diff --git a/test/fixtures/deploy_primary_web_role_override.yml b/test/fixtures/deploy_primary_web_role_override.yml index aa52f5ed..4d90ce58 100644 --- a/test/fixtures/deploy_primary_web_role_override.yml +++ b/test/fixtures/deploy_primary_web_role_override.yml @@ -2,12 +2,12 @@ service: app image: dhh/app servers: web_chicago: - traefik: true + proxy: {} hosts: - 1.1.1.1 - 1.1.1.2 web_tokyo: - traefik: true + proxy: {} hosts: - 1.1.1.3 - 1.1.1.4 diff --git a/test/fixtures/deploy_with_extensions.yml b/test/fixtures/deploy_with_extensions.yml index 4a5f934a..cbbc2e78 100644 --- a/test/fixtures/deploy_with_extensions.yml +++ b/test/fixtures/deploy_with_extensions.yml @@ -1,6 +1,6 @@ x-web: &web - traefik: true + proxy: {} service: app image: dhh/app diff --git a/test/fixtures/deploy_with_multiple_traefik_roles.yml b/test/fixtures/deploy_with_multiple_proxy_roles.yml similarity index 93% rename from test/fixtures/deploy_with_multiple_traefik_roles.yml rename to test/fixtures/deploy_with_multiple_proxy_roles.yml index a1270583..6b785a6a 100644 --- a/test/fixtures/deploy_with_multiple_traefik_roles.yml +++ b/test/fixtures/deploy_with_multiple_proxy_roles.yml @@ -8,14 +8,14 @@ servers: - 1.1.1.2 env: ROLE: "web" - traefik: true + proxy: true web_tokyo: hosts: - 1.1.1.3 - 1.1.1.4 env: ROLE: "web" - traefik: true + proxy: true workers: cmd: bin/jobs hosts: diff --git a/test/fixtures/deploy_with_proxy.yml b/test/fixtures/deploy_with_proxy.yml new file mode 100644 index 00000000..77174ac0 --- /dev/null +++ b/test/fixtures/deploy_with_proxy.yml @@ -0,0 +1,40 @@ +service: app +image: dhh/app +servers: + web: + - "1.1.1.1" + - "1.1.1.2" + workers: + - "1.1.1.3" + - "1.1.1.4" +registry: + username: user + password: pw +builder: + arch: amd64 + + +accessories: + mysql: + image: mysql:5.7 + host: 1.1.1.3 + port: 3306 + env: + clear: + MYSQL_ROOT_HOST: '%' + secret: + - MYSQL_ROOT_PASSWORD + files: + - test/fixtures/files/my.cnf:/etc/mysql/my.cnf + directories: + - data:/var/lib/mysql + redis: + image: redis:latest + roles: + - web + port: 6379 + directories: + - data:/data + +readiness_delay: 0 +deploy_timeout: 6 diff --git a/test/fixtures/deploy_with_proxy_roles.yml b/test/fixtures/deploy_with_proxy_roles.yml new file mode 100644 index 00000000..73211213 --- /dev/null +++ b/test/fixtures/deploy_with_proxy_roles.yml @@ -0,0 +1,46 @@ +service: app +image: dhh/app +servers: + web: + hosts: + - "1.1.1.1" + - "1.1.1.2" + web2: + hosts: + - "1.1.1.3" + - "1.1.1.4" + proxy: + response_timeout: 15 +registry: + username: user + password: pw +builder: + arch: amd64 + +proxy: + response_timeout: 10 + +accessories: + mysql: + image: mysql:5.7 + host: 1.1.1.3 + port: 3306 + env: + clear: + MYSQL_ROOT_HOST: '%' + secret: + - MYSQL_ROOT_PASSWORD + files: + - test/fixtures/files/my.cnf:/etc/mysql/my.cnf + directories: + - data:/var/lib/mysql + redis: + image: redis:latest + roles: + - web + port: 6379 + directories: + - data:/data + +readiness_delay: 0 +deploy_timeout: 6 diff --git a/test/fixtures/deploy_with_roles.yml b/test/fixtures/deploy_with_roles.yml index 1eb8cc5c..6f1656bd 100644 --- a/test/fixtures/deploy_with_roles.yml +++ b/test/fixtures/deploy_with_roles.yml @@ -16,3 +16,4 @@ registry: password: pw builder: arch: amd64 +deploy_timeout: 1 diff --git a/test/fixtures/deploy_workers_only.yml b/test/fixtures/deploy_workers_only.yml index d83adeeb..6f6568c3 100644 --- a/test/fixtures/deploy_workers_only.yml +++ b/test/fixtures/deploy_workers_only.yml @@ -2,7 +2,7 @@ service: app image: dhh/app servers: workers: - traefik: false + proxy: false hosts: - 1.1.1.1 - 1.1.1.2 diff --git a/test/integration/app_test.rb b/test/integration/app_test.rb index b7dcdc34..40a896b1 100644 --- a/test/integration/app_test.rb +++ b/test/integration/app_test.rb @@ -20,34 +20,35 @@ class AppTest < IntegrationTest wait_for_app_to_be_up logs = kamal :app, :logs, capture: true - assert_match /App Host: vm1/, logs - assert_match /App Host: vm2/, logs - assert_match /GET \/ HTTP\/1.1/, logs + assert_match "App Host: vm1", logs + assert_match "App Host: vm2", logs + assert_match "GET /version HTTP/1.1", logs images = kamal :app, :images, capture: true - assert_match /App Host: vm1/, images - assert_match /App Host: vm2/, images + assert_match "App Host: vm1", images + assert_match "App Host: vm2", images assert_match /registry:4443\/app\s+#{latest_app_version}/, images assert_match /registry:4443\/app\s+latest/, images containers = kamal :app, :containers, capture: true - assert_match /App Host: vm1/, containers - assert_match /App Host: vm2/, containers - assert_match /registry:4443\/app:#{latest_app_version}/, containers - assert_match /registry:4443\/app:latest/, containers + assert_match "App Host: vm1", containers + assert_match "App Host: vm2", containers + assert_match "registry:4443/app:#{latest_app_version}", containers + assert_match "registry:4443/app:latest", containers exec_output = kamal :app, :exec, :ps, capture: true - assert_match /App Host: vm1/, exec_output - assert_match /App Host: vm2/, exec_output + assert_match "App Host: vm1", exec_output + assert_match "App Host: vm2", exec_output assert_match /1 root 0:\d\d ps/, exec_output exec_output = kamal :app, :exec, "--reuse", :ps, capture: true - assert_match /App Host: vm1/, exec_output - assert_match /App Host: vm2/, exec_output + assert_match "App Host: vm2", exec_output + assert_match "App Host: vm1", exec_output assert_match /1 root 0:\d\d nginx/, exec_output kamal :app, :remove assert_app_is_down + assert_app_directory_removed end end diff --git a/test/integration/broken_deploy_test.rb b/test/integration/broken_deploy_test.rb index 77f0ff96..5d69416a 100644 --- a/test/integration/broken_deploy_test.rb +++ b/test/integration/broken_deploy_test.rb @@ -9,7 +9,7 @@ class BrokenDeployTest < IntegrationTest kamal :deploy assert_app_is_up version: first_version - assert_container_running host: :vm3, name: "app-workers-#{first_version}" + assert_container_running host: :vm3, name: "app_with_roles-workers-#{first_version}" second_version = break_app @@ -17,8 +17,8 @@ class BrokenDeployTest < IntegrationTest assert_failed_deploy output assert_app_is_up version: first_version - assert_container_running host: :vm3, name: "app-workers-#{first_version}" - assert_container_not_running host: :vm3, name: "app-workers-#{second_version}" + assert_container_running host: :vm3, name: "app_with_roles-workers-#{first_version}" + assert_container_not_running host: :vm3, name: "app_with_roles-workers-#{second_version}" end private @@ -27,6 +27,5 @@ class BrokenDeployTest < IntegrationTest assert_match /First web container is unhealthy on vm[12], not booting any other roles/, output assert_match "First web container is unhealthy, not booting workers on vm3", output assert_match "nginx: [emerg] unexpected end of file, expecting \";\" or \"}\" in /etc/nginx/conf.d/default.conf:2", output - assert_match 'ERROR {"Status":"unhealthy","FailingStreak":0,"Log":[]}', output end end diff --git a/test/integration/docker/deployer/app/.kamal/hooks/post-proxy-reboot b/test/integration/docker/deployer/app/.kamal/hooks/post-proxy-reboot new file mode 100755 index 00000000..fb47d3cc --- /dev/null +++ b/test/integration/docker/deployer/app/.kamal/hooks/post-proxy-reboot @@ -0,0 +1,3 @@ +#!/bin/sh +echo "Rebooted kamal-proxy on ${KAMAL_HOSTS}" +mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-proxy-reboot diff --git a/test/integration/docker/deployer/app/.kamal/hooks/post-traefik-reboot b/test/integration/docker/deployer/app/.kamal/hooks/post-traefik-reboot deleted file mode 100755 index 598ddb63..00000000 --- a/test/integration/docker/deployer/app/.kamal/hooks/post-traefik-reboot +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -echo "Rebooted Traefik on ${KAMAL_HOSTS}" -mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-traefik-reboot diff --git a/test/integration/docker/deployer/app/.kamal/hooks/pre-proxy-reboot b/test/integration/docker/deployer/app/.kamal/hooks/pre-proxy-reboot new file mode 100755 index 00000000..65a70a00 --- /dev/null +++ b/test/integration/docker/deployer/app/.kamal/hooks/pre-proxy-reboot @@ -0,0 +1,3 @@ +#!/bin/sh +echo "Rebooting Traefik on ${KAMAL_HOSTS}..." +mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-proxy-reboot diff --git a/test/integration/docker/deployer/app/.kamal/hooks/pre-traefik-reboot b/test/integration/docker/deployer/app/.kamal/hooks/pre-traefik-reboot deleted file mode 100755 index 81269d24..00000000 --- a/test/integration/docker/deployer/app/.kamal/hooks/pre-traefik-reboot +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -echo "Rebooting Traefik on ${KAMAL_HOSTS}..." -mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-traefik-reboot diff --git a/test/integration/docker/deployer/app/Dockerfile b/test/integration/docker/deployer/app/Dockerfile index dc270aa9..0e6237df 100644 --- a/test/integration/docker/deployer/app/Dockerfile +++ b/test/integration/docker/deployer/app/Dockerfile @@ -6,4 +6,4 @@ ARG COMMIT_SHA RUN echo $COMMIT_SHA > /usr/share/nginx/html/version RUN mkdir -p /usr/share/nginx/html/versions && echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA RUN mkdir -p /usr/share/nginx/html/versions && echo "hidden" > /usr/share/nginx/html/versions/.hidden - +RUN echo "Up!" > /usr/share/nginx/html/up diff --git a/test/integration/docker/deployer/app/config/deploy.yml b/test/integration/docker/deployer/app/config/deploy.yml index a85412ad..242d893a 100644 --- a/test/integration/docker/deployer/app/config/deploy.yml +++ b/test/integration/docker/deployer/app/config/deploy.yml @@ -20,7 +20,11 @@ env: secret: - SECRET_TAG asset_path: /usr/share/nginx/html/versions - +deploy_timeout: 2 +drain_timeout: 2 +readiness_delay: 0 +proxy: + host: 127.0.0.1 registry: server: registry:4443 username: root @@ -30,14 +34,6 @@ builder: arch: <%= Kamal::Utils.docker_arch %> args: COMMIT_SHA: <%= `git rev-parse HEAD` %> -healthcheck: - cmd: wget -qO- http://localhost > /dev/null || exit 1 - max_attempts: 3 -traefik: - args: - accesslog: true - accesslog.format: json - image: registry:4443/traefik:v2.10 accessories: busybox: service: custom-busybox @@ -45,5 +41,3 @@ accessories: cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' roles: - web -stop_wait_time: 1 -readiness_delay: 0 diff --git a/test/integration/docker/deployer/app_with_roles/.kamal/hooks/post-proxy-reboot b/test/integration/docker/deployer/app_with_roles/.kamal/hooks/post-proxy-reboot new file mode 100755 index 00000000..fb47d3cc --- /dev/null +++ b/test/integration/docker/deployer/app_with_roles/.kamal/hooks/post-proxy-reboot @@ -0,0 +1,3 @@ +#!/bin/sh +echo "Rebooted kamal-proxy on ${KAMAL_HOSTS}" +mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-proxy-reboot diff --git a/test/integration/docker/deployer/app_with_roles/.kamal/hooks/post-traefik-reboot b/test/integration/docker/deployer/app_with_roles/.kamal/hooks/post-traefik-reboot deleted file mode 100755 index 598ddb63..00000000 --- a/test/integration/docker/deployer/app_with_roles/.kamal/hooks/post-traefik-reboot +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -echo "Rebooted Traefik on ${KAMAL_HOSTS}" -mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-traefik-reboot diff --git a/test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-proxy-reboot b/test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-proxy-reboot new file mode 100755 index 00000000..6413b6ef --- /dev/null +++ b/test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-proxy-reboot @@ -0,0 +1,3 @@ +#!/bin/sh +echo "Rebooting kamal-proxy on ${KAMAL_HOSTS}..." +mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-proxy-reboot diff --git a/test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-traefik-reboot b/test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-traefik-reboot deleted file mode 100755 index 81269d24..00000000 --- a/test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-traefik-reboot +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -echo "Rebooting Traefik on ${KAMAL_HOSTS}..." -mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-traefik-reboot diff --git a/test/integration/docker/deployer/app_with_roles/Dockerfile b/test/integration/docker/deployer/app_with_roles/Dockerfile index dc270aa9..0e6237df 100644 --- a/test/integration/docker/deployer/app_with_roles/Dockerfile +++ b/test/integration/docker/deployer/app_with_roles/Dockerfile @@ -6,4 +6,4 @@ ARG COMMIT_SHA RUN echo $COMMIT_SHA > /usr/share/nginx/html/version RUN mkdir -p /usr/share/nginx/html/versions && echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA RUN mkdir -p /usr/share/nginx/html/versions && echo "hidden" > /usr/share/nginx/html/versions/.hidden - +RUN echo "Up!" > /usr/share/nginx/html/up diff --git a/test/integration/docker/deployer/app_with_roles/config/deploy.yml b/test/integration/docker/deployer/app_with_roles/config/deploy.yml index 3b942665..b31fb4fd 100644 --- a/test/integration/docker/deployer/app_with_roles/config/deploy.yml +++ b/test/integration/docker/deployer/app_with_roles/config/deploy.yml @@ -1,5 +1,5 @@ -service: app -image: app +service: app_with_roles +image: app_with_roles servers: web: hosts: @@ -9,6 +9,31 @@ servers: hosts: - vm3 cmd: sleep infinity +deploy_timeout: 2 +drain_timeout: 2 +readiness_delay: 0 + +proxy: + host: localhost + healthcheck: + interval: 1 + timeout: 1 + path: "/up" + response_timeout: 2 + buffering: + requests: true + responses: true + memory: 400_000 + max_request_body: 40_000_000 + max_response_body: 40_000_000 + forward_headers: true + logging: + request_headers: + - Cache-Control + - X-Forwarded-Proto + response_headers: + - X-Request-ID + - X-Request-Start asset_path: /usr/share/nginx/html/versions @@ -21,14 +46,6 @@ builder: arch: <%= Kamal::Utils.docker_arch %> args: COMMIT_SHA: <%= `git rev-parse HEAD` %> -healthcheck: - cmd: wget -qO- http://localhost > /dev/null || exit 1 - max_attempts: 3 -traefik: - args: - accesslog: true - accesslog.format: json - image: registry:4443/traefik:v2.10 accessories: busybox: service: custom-busybox @@ -36,8 +53,6 @@ accessories: cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' roles: - web -stop_wait_time: 1 -readiness_delay: 0 aliases: whome: version worker_hostname: app exec -r workers -q --reuse hostname diff --git a/test/integration/docker/deployer/setup.sh b/test/integration/docker/deployer/setup.sh index 0cd511d9..4867519e 100755 --- a/test/integration/docker/deployer/setup.sh +++ b/test/integration/docker/deployer/setup.sh @@ -19,7 +19,6 @@ push_image_to_registry_4443() { install_kamal push_image_to_registry_4443 nginx 1-alpine-slim -push_image_to_registry_4443 traefik v2.10 push_image_to_registry_4443 busybox 1.36.0 # .ssh is on a shared volume that persists between runs. Clean it up as the diff --git a/test/integration/docker/load_balancer/default.conf b/test/integration/docker/load_balancer/default.conf index 82d49973..79bbb89f 100644 --- a/test/integration/docker/load_balancer/default.conf +++ b/test/integration/docker/load_balancer/default.conf @@ -8,6 +8,8 @@ server { location / { proxy_pass http://loadbalancer; + proxy_set_header Host $host; + proxy_connect_timeout 10; proxy_send_timeout 10; proxy_read_timeout 10; diff --git a/test/integration/integration_test.rb b/test/integration/integration_test.rb index ea445d9e..ad99f3e2 100644 --- a/test/integration/integration_test.rb +++ b/test/integration/integration_test.rb @@ -31,7 +31,7 @@ class IntegrationTest < ActiveSupport::TestCase succeeded = system("cd test/integration && #{command}") end - raise "Command `#{command}` failed with error code `#{$?}`" if !succeeded && raise_on_error + raise "Command `#{command}` failed with error code `#{$?}`, and output:\n#{result}" if !succeeded && raise_on_error result end @@ -50,8 +50,8 @@ class IntegrationTest < ActiveSupport::TestCase assert_equal "502", response.code end - def assert_app_is_up(version: nil) - response = app_response + def assert_app_is_up(version: nil, app: @app) + response = app_response(app: app) debug_response_code(response, "200") assert_equal "200", response.code assert_app_version(version, response) if version @@ -69,8 +69,8 @@ class IntegrationTest < ActiveSupport::TestCase assert_equal up_times, up_count end - def app_response - Net::HTTP.get_response(URI.parse("http://localhost:12345/version")) + def app_response(app: @app) + Net::HTTP.get_response(URI.parse("http://#{app_host(app)}:12345/version")) end def update_app_rev @@ -101,8 +101,8 @@ class IntegrationTest < ActiveSupport::TestCase def assert_200(response) code = response.code if code != "200" - puts "Got response code #{code}, here are the traefik logs:" - kamal :traefik, :logs + puts "Got response code #{code}, here are the proxy logs:" + kamal :proxy, :logs puts "And here are the load balancer logs" docker_compose :logs, :load_balancer puts "Tried to get the response code again and got #{app_response.code}" @@ -129,8 +129,8 @@ class IntegrationTest < ActiveSupport::TestCase def debug_response_code(app_response, expected_code) code = app_response.code if code != expected_code - puts "Got response code #{code}, here are the traefik logs:" - kamal :traefik, :logs + puts "Got response code #{code}, here are the proxy logs:" + kamal :proxy, :logs puts "And here are the load balancer logs" docker_compose :logs, :load_balancer puts "Tried to get the response code again and got #{app_response.code}" @@ -148,4 +148,31 @@ class IntegrationTest < ActiveSupport::TestCase def container_running?(host:, name:) docker_compose("exec #{host} docker ps --filter=name=#{name} | tail -n+2", capture: true).strip.present? end + + def assert_app_directory_removed + assert_directory_removed("./kamal/apps/#{@app}") + end + + def assert_directory_removed(directory) + assert docker_compose("exec vm1 ls #{directory} | wc -l", capture: true).strip == "0" + end + + def assert_proxy_running + assert_container_running(host: "vm1", name: "kamal-proxy") + end + + def assert_proxy_not_running + assert_container_not_running(host: "vm1", name: "kamal-proxy") + end + + def app_host(app = @app) + case app + when "app" + "127.0.0.1" + when "app_with_roles" + "localhost" + else + raise "Unknown app: #{app}" + end + end end diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index b58aeeb9..e3aa1ef3 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -24,11 +24,11 @@ class MainTest < IntegrationTest assert_app_is_up version: first_version details = kamal :details, capture: true - assert_match /Traefik Host: vm1/, details - assert_match /Traefik Host: vm2/, details + assert_match /Proxy Host: vm1/, details + assert_match /Proxy Host: vm2/, details assert_match /App Host: vm1/, details assert_match /App Host: vm2/, details - assert_match /traefik:v2.10/, details + assert_match /basecamp\/kamal-proxy:#{Kamal::Configuration::PROXY_MINIMUM_VERSION}/, details assert_match /registry:4443\/app:#{first_version}/, details audit = kamal :audit, capture: true @@ -46,13 +46,13 @@ class MainTest < IntegrationTest assert_app_is_up version: version assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy" - assert_container_running host: :vm3, name: "app-workers-#{version}" + assert_container_running host: :vm3, name: "app_with_roles-workers-#{version}" second_version = update_app_rev kamal :redeploy assert_app_is_up version: second_version - assert_container_running host: :vm3, name: "app-workers-#{second_version}" + assert_container_running host: :vm3, name: "app_with_roles-workers-#{second_version}" end test "config" do @@ -70,13 +70,11 @@ class MainTest < IntegrationTest assert_equal({ user: "root", port: 22, keepalive: true, keepalive_interval: 30, log_level: :fatal }, config[:ssh_options]) assert_equal({ "driver" => "docker", "arch" => "#{Kamal::Utils.docker_arch}", "args" => { "COMMIT_SHA" => version } }, config[:builder]) assert_equal [ "--log-opt", "max-size=\"10m\"" ], config[:logging] - assert_equal({ "cmd"=>"wget -qO- http://localhost > /dev/null || exit 1", "interval"=>"1s", "max_attempts"=>3, "port"=>3000, "path"=>"/up", "cord"=>"/tmp/kamal-cord", "log_lines"=>50 }, config[:healthcheck]) end test "aliases" do @app = "app_with_roles" - kamal :envify kamal :deploy output = kamal :whome, capture: true @@ -99,6 +97,30 @@ class MainTest < IntegrationTest kamal :remove, "-y" assert_no_images_or_containers + assert_app_directory_removed + end + + test "two apps" do + @app = "app" + kamal :deploy + app1_version = latest_app_version + + @app = "app_with_roles" + kamal :deploy + app2_version = latest_app_version + + assert_app_is_up version: app1_version, app: "app" + assert_app_is_up version: app2_version, app: "app_with_roles" + + @app = "app" + kamal :remove, "-y" + assert_app_directory_removed + assert_proxy_running + + @app = "app_with_roles" + kamal :remove, "-y" + assert_app_directory_removed + assert_proxy_not_running end private @@ -116,21 +138,21 @@ class MainTest < IntegrationTest end def assert_env(key, value, vm:, version:) - assert_equal "#{key}=#{value}", docker_compose("exec #{vm} docker exec app-web-#{version} env | grep #{key}", capture: true) + assert_equal "#{key}=#{value}", docker_compose("exec #{vm} docker exec #{@app}-web-#{version} env | grep #{key}", capture: true) end def assert_no_env(key, vm:, version:) assert_raises(RuntimeError, /exit 1/) do - docker_compose("exec #{vm} docker exec app-web-#{version} env | grep #{key}", capture: true) + docker_compose("exec #{vm} docker exec #{@app}-web-#{version} env | grep #{key}", capture: true) end end def assert_accumulated_assets(*versions) versions.each do |version| - assert_equal "200", Net::HTTP.get_response(URI.parse("http://localhost:12345/versions/#{version}")).code + assert_equal "200", Net::HTTP.get_response(URI.parse("http://#{app_host}:12345/versions/#{version}")).code end - assert_equal "200", Net::HTTP.get_response(URI.parse("http://localhost:12345/versions/.hidden")).code + assert_equal "200", Net::HTTP.get_response(URI.parse("http://#{app_host}:12345/versions/.hidden")).code end def vm1_image_ids diff --git a/test/integration/proxy_test.rb b/test/integration/proxy_test.rb new file mode 100644 index 00000000..d861a887 --- /dev/null +++ b/test/integration/proxy_test.rb @@ -0,0 +1,52 @@ +require_relative "integration_test" + +class ProxyTest < IntegrationTest + setup do + @app = "app_with_roles" + end + + test "boot, reboot, stop, start, restart, logs, remove" do + kamal :proxy, :boot + assert_proxy_running + + output = kamal :proxy, :reboot, "-y", "--verbose", capture: true + assert_proxy_running + assert_hooks_ran "pre-proxy-reboot", "post-proxy-reboot" + assert_match /Rebooting kamal-proxy on vm1,vm2.../, output + assert_match /Rebooted kamal-proxy on vm1,vm2/, output + + output = kamal :proxy, :reboot, "--rolling", "-y", "--verbose", capture: true + assert_proxy_running + assert_hooks_ran "pre-proxy-reboot", "post-proxy-reboot" + assert_match /Rebooting kamal-proxy on vm1.../, output + assert_match /Rebooted kamal-proxy on vm1/, output + assert_match /Rebooting kamal-proxy on vm2.../, output + assert_match /Rebooted kamal-proxy on vm2/, output + + kamal :proxy, :boot + assert_proxy_running + + # Check booting when booted doesn't raise an error + kamal :proxy, :stop + assert_proxy_not_running + + # Check booting when stopped works + kamal :proxy, :boot + assert_proxy_running + + kamal :proxy, :stop + assert_proxy_not_running + + kamal :proxy, :start + assert_proxy_running + + kamal :proxy, :restart + assert_proxy_running + + logs = kamal :proxy, :logs, capture: true + assert_match /No previous state to restore/, logs + + kamal :proxy, :remove + assert_proxy_not_running + end +end diff --git a/test/integration/traefik_test.rb b/test/integration/traefik_test.rb deleted file mode 100644 index 48f9ea02..00000000 --- a/test/integration/traefik_test.rb +++ /dev/null @@ -1,61 +0,0 @@ -require_relative "integration_test" - -class TraefikTest < IntegrationTest - test "boot, reboot, stop, start, restart, logs, remove" do - kamal :traefik, :boot - assert_traefik_running - - output = kamal :traefik, :reboot, "-y", "--verbose", capture: true - assert_traefik_running - assert_hooks_ran "pre-traefik-reboot", "post-traefik-reboot" - assert_match /Rebooting Traefik on vm1,vm2.../, output - assert_match /Rebooted Traefik on vm1,vm2/, output - - output = kamal :traefik, :reboot, "--rolling", "-y", "--verbose", capture: true - assert_traefik_running - assert_hooks_ran "pre-traefik-reboot", "post-traefik-reboot" - assert_match /Rebooting Traefik on vm1.../, output - assert_match /Rebooted Traefik on vm1/, output - assert_match /Rebooting Traefik on vm2.../, output - assert_match /Rebooted Traefik on vm2/, output - - kamal :traefik, :boot - assert_traefik_running - - # Check booting when booted doesn't raise an error - kamal :traefik, :stop - assert_traefik_not_running - - # Check booting when stopped works - kamal :traefik, :boot - assert_traefik_running - - kamal :traefik, :stop - assert_traefik_not_running - - kamal :traefik, :start - assert_traefik_running - - kamal :traefik, :restart - assert_traefik_running - - logs = kamal :traefik, :logs, capture: true - assert_match /Traefik version [\d.]+ built on/, logs - - kamal :traefik, :remove - assert_traefik_not_running - end - - private - def assert_traefik_running - assert_match /traefik:v2.10 "\/entrypoint.sh/, traefik_details - end - - def assert_traefik_not_running - assert_no_match /traefik:v2.10 "\/entrypoint.sh/, traefik_details - end - - def traefik_details - kamal :traefik, :details, capture: true - end -end