Merge branch 'basecamp:main' into buildpacks

This commit is contained in:
Nick Hammond
2024-09-22 08:47:08 -07:00
committed by GitHub
104 changed files with 2027 additions and 1682 deletions

View File

@@ -1,4 +1,5 @@
module Kamal::Cli
class BootError < StandardError; end
class HookError < StandardError; end
class LockError < StandardError; end
end

View File

@@ -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

View File

@@ -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|

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

215
lib/kamal/cli/proxy.rb Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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?

View File

@@ -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.

View File

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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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 \"{}\" +"
]

View File

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

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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-<DESTINATION>` 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

View File

@@ -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:<port>/<path>`, 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:<port>/<path>` 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

View File

@@ -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

View File

@@ -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.
# Heres the final configuration:

View File

@@ -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

View File

@@ -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:
...

View File

@@ -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

View File

@@ -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

View File

@@ -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?

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -1,3 +1,3 @@
module Kamal
VERSION = "2.0.0.alpha"
VERSION = "2.0.0.rc2"
end