Compare commits

...

29 Commits

Author SHA1 Message Date
Donal McBreen
9a5880208a Rename roles
Allow roles to be renamed without having to manually stop the old
containers.

If you have config like:

```
servers:
  jobs:
    hosts:
      - vm3
```

And you want to rename `jobs` to `workers`, you can do:

```
servers:
  workers:
    previously:
      - jobs
    hosts:
      - vm3
```

And the deployment will take care of stopping the old "jobs" containers.

Once deployed you can remove the `previously` key.
2024-04-26 11:47:57 +01:00
Donal McBreen
d475e88dbe Bump version for 1.5.0 2024-04-25 13:39:06 +01:00
Donal McBreen
d551f044d6 Merge pull request #772 from aishek/take-accessory-hosts-into-account
Take accessory hosts into account for --hosts
2024-04-25 11:57:45 +01:00
Donal McBreen
2611179d5e Merge pull request #773 from xiaohui-zhangxh/docker-env-file-keep-non-ascii
don't escape non-ascii characters in docker env file
2024-04-25 11:57:14 +01:00
Donal McBreen
1a013b8d4b Merge pull request #770 from ttilberg/769-ensure-valid-service-name-with-capital-letters
Allow capital letters to match valid service name, such as in MyApp
2024-04-25 11:52:55 +01:00
xiaohui
9a9a0914cd don't escape non-ascii characters in docker env file 2024-04-17 17:42:06 +08:00
Alexandr Borisov
12c518097f Take accessory hosts into account 2024-04-17 11:45:33 +03:00
Tim Tilberg
69f90387a8 Allow capital letters to match valid service name, such as in MyApp 2024-04-15 09:09:58 -05:00
Donal McBreen
31669d4dce Merge pull request #758 from basecamp/any-runtime-in-test
Accept any runtime in the hook tests
2024-04-03 16:27:59 +01:00
Donal McBreen
9d20c1466e Merge pull request #757 from basecamp/executable-sample-docker-setup-hook
Make the sample docker setup hook executable
2024-04-03 16:12:46 +01:00
Donal McBreen
ff1dabe7f8 Merge pull request #756 from basecamp/tidy-up-role-host-setup
Tidy up role and host commander setup
2024-04-03 16:09:12 +01:00
Donal McBreen
69aa422890 Accept any runtime in the hook tests
Occasionally in CI things run slowly and it takes more that 1 second
for a cli test to run, so let's allow any value for the runtime in the
hook checks.
2024-04-03 16:06:53 +01:00
Donal McBreen
f8b0883036 Make the sample docker setup hook executable
Fixes https://github.com/basecamp/kamal/issues/754
2024-04-03 15:50:47 +01:00
Donal McBreen
c8100d1f26 Tidy up role and host commander setup
Extract Kamal::Commander::Specifics to deal with host and role setup and
ensure that primary hosts and roles always come first. This means that
in a rolling deploy we deploy to the primary ones first.

This will be important when we remove the healthcheck step as we want
to confirm the primary host can be deployed to before completing a
deployment for other roles.

By setting the hosts and roles all together in one place we can sort
the primary ones to the front without creating infinite loops.
2024-04-03 15:46:30 +01:00
Donal McBreen
3628ecaa44 Merge pull request #753 from basecamp/dump-hook-output-on-failure
Include error message on failure
2024-04-03 10:52:49 +01:00
Donal McBreen
67a2d5e7ca Include error message on failure 2024-04-03 10:43:20 +01:00
Donal McBreen
5e492ecc4d Merge pull request #748 from basecamp/latest-by-tag
Latest by tag
2024-04-03 09:11:03 +01:00
Donal McBreen
77bad291a1 Merge pull request #751 from basecamp/app-exec-env
Set env variables when running kamal app exec
2024-04-02 11:03:38 +01:00
Donal McBreen
a0ce9f66c4 Merge pull request #752 from basecamp/dont-debug-hooks
Use default verbosity for hooks
2024-04-02 11:03:24 +01:00
Donal McBreen
82962c375d Use default verbosity for hooks 2024-04-02 10:47:05 +01:00
Donal McBreen
8a6a51977f Set env variables when running kamal app exec
Allow additional env variable to be set when running `kamal app exec`.
Works for both new and existing containers.
2024-04-01 15:01:32 +01:00
Donal McBreen
2562853ae3 Merge pull request #746 from igor-alexandrov/file-join
Replaced string interpolations with File.join to build paths
2024-03-29 13:47:24 +00:00
Donal McBreen
ed90b99f0d Add tag_latest_image tests 2024-03-29 10:51:57 +00:00
Donal McBreen
ba7a13f895 Only tag after deploying to all hosts 2024-03-29 10:29:58 +00:00
Donal McBreen
05ac808f2a Use image tag to determine stale containers
Use current_running_version to determine the latest version when finding
stale containers.
2024-03-29 10:23:50 +00:00
Donal McBreen
fb7d9077ff Use latest tag for the current destination 2024-03-29 09:48:09 +00:00
Donal McBreen
bade195e93 Redefine what the "latest" container means
Currently the latest container is the one that was created last. But if
we have had a failed deployment that left two containers running that
would not be the one we want. The second container could be in a
restart loop for example.

Instead we want the container that is running the image tagged as
latest. As we now tag as latest after a successful deployment we can
trust that that is a healthy container.

In the case that there is no container running the latest image tag,
we'll fall back to the latest container.

This could happen if the deploy was halted in between the old container
being stopped and the image being tagged as latest.
2024-03-29 08:51:50 +00:00
Donal McBreen
55dd2f49c1 Tag image after booting and include destination
If you are deploying more than one destination to a host, the latest
tags will conflict, so we'll append the destination to the tag.

The latest tag is used when booting the app or exec-ing a new container.

If a deploy doesn't complete on a host for all roles then we should
probably not be using it, so move the tagging to the end of the boot
process.
2024-03-29 08:51:50 +00:00
Igor Alexandrov
511a182539 Replaced string interpolations with to build paths 2024-03-28 20:25:24 +04:00
39 changed files with 404 additions and 171 deletions

View File

@@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
kamal (1.4.0) kamal (1.5.0)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)

View File

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

View File

@@ -9,9 +9,6 @@ class Kamal::Cli::App < Kamal::Cli::Base
# Assets are prepared in a separate step to ensure they are on all hosts before booting # Assets are prepared in a separate step to ensure they are on all hosts before booting
on(KAMAL.hosts) do on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug
execute *KAMAL.app.tag_current_image_as_latest
KAMAL.roles_on(host).each do |role| KAMAL.roles_on(host).each do |role|
Kamal::Cli::App::PrepareAssets.new(host, role, self).run Kamal::Cli::App::PrepareAssets.new(host, role, self).run
end end
@@ -22,6 +19,11 @@ class Kamal::Cli::App < Kamal::Cli::Base
Kamal::Cli::App::Boot.new(host, role, version, self).run Kamal::Cli::App::Boot.new(host, role, version, self).run
end end
end end
on(KAMAL.hosts) do |host|
execute *KAMAL.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug
execute *KAMAL.app.tag_latest_image
end
end end
end end
end end
@@ -70,13 +72,15 @@ class Kamal::Cli::App < Kamal::Cli::Base
desc "exec [CMD]", "Execute a custom command on servers (use --help to show options)" desc "exec [CMD]", "Execute a custom command on servers (use --help to show options)"
option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)" option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one" option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
option :env, aliases: "-e", type: :hash, desc: "Set environment variables for the command"
def exec(cmd) def exec(cmd)
env = options[:env]
case case
when options[:interactive] && options[:reuse] when options[:interactive] && options[:reuse]
say "Get current version of running container...", :magenta unless options[:version] say "Get current version of running container...", :magenta unless options[:version]
using_version(options[:version] || current_running_version) do |version| using_version(options[:version] || current_running_version) do |version|
say "Launching interactive command with version #{version} via SSH from existing container on #{KAMAL.primary_host}...", :magenta say "Launching interactive command with version #{version} via SSH from existing container on #{KAMAL.primary_host}...", :magenta
run_locally { exec KAMAL.app(role: KAMAL.primary_role).execute_in_existing_container_over_ssh(cmd, host: KAMAL.primary_host) } run_locally { exec KAMAL.app(role: KAMAL.primary_role).execute_in_existing_container_over_ssh(cmd, host: KAMAL.primary_host, env: env) }
end end
when options[:interactive] when options[:interactive]
@@ -84,7 +88,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
using_version(version_or_latest) do |version| using_version(version_or_latest) do |version|
say "Launching interactive command with version #{version} via SSH from new container on #{KAMAL.primary_host}...", :magenta say "Launching interactive command with version #{version} via SSH from new container on #{KAMAL.primary_host}...", :magenta
run_locally do run_locally do
exec KAMAL.app(role: KAMAL.primary_role).execute_in_new_container_over_ssh(cmd, host: KAMAL.primary_host) exec KAMAL.app(role: KAMAL.primary_role).execute_in_new_container_over_ssh(cmd, host: KAMAL.primary_host, env: env)
end end
end end
@@ -98,7 +102,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
roles.each do |role| roles.each do |role|
execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}", role: role), verbosity: :debug execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}", role: role), verbosity: :debug
puts_by_host host, capture_with_info(*KAMAL.app(role: role).execute_in_existing_container(cmd)) puts_by_host host, capture_with_info(*KAMAL.app(role: role).execute_in_existing_container(cmd, env: env))
end end
end end
end end
@@ -112,7 +116,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
roles.each do |role| roles.each do |role|
execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
puts_by_host host, capture_with_info(*KAMAL.app(role: role).execute_in_new_container(cmd)) puts_by_host host, capture_with_info(*KAMAL.app(role: role).execute_in_new_container(cmd, env: env))
end end
end end
end end
@@ -136,7 +140,10 @@ class Kamal::Cli::App < Kamal::Cli::Base
roles = KAMAL.roles_on(host) roles = KAMAL.roles_on(host)
roles.each do |role| roles.each do |role|
cli.send(:stale_versions, host: host, role: role).each do |version| versions = capture_with_info(*KAMAL.app(role: role).list_versions, raise_on_non_zero_exit: false).split("\n")
versions -= [ capture_with_info(*KAMAL.app(role: role).current_running_version, raise_on_non_zero_exit: false).strip ]
versions.each do |version|
if stop if stop
puts_by_host host, "Stopping stale container for role #{role} with version #{version}" puts_by_host host, "Stopping stale container for role #{role} with version #{version}"
execute *KAMAL.app(role: role).stop(version: version), raise_on_non_zero_exit: false execute *KAMAL.app(role: role).stop(version: version), raise_on_non_zero_exit: false
@@ -272,18 +279,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
version.presence version.presence
end end
def stale_versions(host:, role:)
versions = nil
on(host) do
versions = \
capture_with_info(*KAMAL.app(role: role).list_versions, raise_on_non_zero_exit: false)
.split("\n")
.drop(1)
end
versions
end
def version_or_latest def version_or_latest
options[:version] || "latest" options[:version] || KAMAL.config.latest_tag
end end
end end

View File

@@ -11,12 +11,12 @@ class Kamal::Cli::App::Boot
end end
def run def run
old_version = old_version_renamed_if_clashing old_version, old_app = old_version_renamed_if_clashing
start_new_version start_new_version
if old_version if old_version
stop_old_version(old_version) stop_old_version(old_version, old_app)
end end
end end
@@ -41,7 +41,13 @@ class Kamal::Cli::App::Boot
execute *app.rename_container(version: version, new_version: renamed_version) execute *app.rename_container(version: version, new_version: renamed_version)
end end
capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip.presence [ role, *role.previous_roles ].each do |old_role|
old_app = KAMAL.app(role: old_role)
old_version = capture_with_info(*old_app.current_running_version, raise_on_non_zero_exit: false).strip.presence
return [ old_version, old_app ] if old_version
end
nil
end end
def start_new_version def start_new_version
@@ -51,7 +57,7 @@ class Kamal::Cli::App::Boot
Kamal::Cli::Healthcheck::Poller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) } Kamal::Cli::Healthcheck::Poller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) }
end end
def stop_old_version(version) def stop_old_version(version, app)
if uses_cord? if uses_cord?
cord = capture_with_info(*app.cord(version: version), raise_on_non_zero_exit: false).strip cord = capture_with_info(*app.cord(version: version), raise_on_non_zero_exit: false).strip
if cord.present? if cord.present?

View File

@@ -157,9 +157,9 @@ module Kamal::Cli
say "Running the #{hook} hook...", :magenta say "Running the #{hook} hook...", :magenta
run_locally do run_locally do
KAMAL.with_verbosity(:debug) { execute *KAMAL.hook.run(hook, **details, **extra_details) } execute *KAMAL.hook.run(hook, **details, **extra_details)
rescue SSHKit::Command::Failed rescue SSHKit::Command::Failed => e
raise HookError.new("Hook `#{hook}` failed") raise HookError.new("Hook `#{hook}` failed:\n#{e.message}")
end end
end end
end end

View File

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ class Kamal::Commands::App < Kamal::Commands::Base
ACTIVE_DOCKER_STATUSES = [ :running, :restarting ] ACTIVE_DOCKER_STATUSES = [ :running, :restarting ]
attr_reader :role, :role attr_reader :role
def initialize(config, role: nil) def initialize(config, role: nil)
super(config) super(config)
@@ -49,7 +49,7 @@ class Kamal::Commands::App < Kamal::Commands::Base
def current_running_container_id def current_running_container_id
docker :ps, "--quiet", *filter_args(statuses: ACTIVE_DOCKER_STATUSES), "--latest" current_running_container(format: "--quiet")
end end
def container_id_for_version(version, only_running: false) def container_id_for_version(version, only_running: false)
@@ -57,13 +57,15 @@ class Kamal::Commands::App < Kamal::Commands::Base
end end
def current_running_version def current_running_version
list_versions("--latest", statuses: ACTIVE_DOCKER_STATUSES) pipe \
current_running_container(format: "--format '{{.Names}}'"),
extract_version_from_name
end end
def list_versions(*docker_args, statuses: nil) def list_versions(*docker_args, statuses: nil)
pipe \ pipe \
docker(:ps, *filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'), docker(:ps, *filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'),
%(while read line; do echo ${line##{role.container_prefix}-}; done) # Extract SHA from "service-role-dest-SHA" extract_version_from_name
end end
@@ -81,10 +83,33 @@ class Kamal::Commands::App < Kamal::Commands::Base
[ role.container_prefix, version || config.version ].compact.join("-") [ role.container_prefix, version || config.version ].compact.join("-")
end end
def latest_image_id
docker :image, :ls, *argumentize("--filter", "reference=#{config.latest_image}"), "--format", "'{{.ID}}'"
end
def current_running_container(format:)
pipe \
shell(chain(latest_image_container(format: format), latest_container(format: format))),
[ :head, "-1" ]
end
def latest_image_container(format:)
latest_container format: format, filters: [ "ancestor=$(#{latest_image_id.join(" ")})" ]
end
def latest_container(format:, filters: nil)
docker :ps, "--latest", *format, *filter_args(statuses: ACTIVE_DOCKER_STATUSES), argumentize("--filter", filters)
end
def filter_args(statuses: nil) def filter_args(statuses: nil)
argumentize "--filter", filters(statuses: statuses) argumentize "--filter", filters(statuses: statuses)
end end
def extract_version_from_name
# Extract SHA from "service-role-dest-SHA"
%(while read line; do echo ${line##{role.container_prefix}-}; done)
end
def filters(statuses: nil) def filters(statuses: nil)
[ "label=service=#{config.service}" ].tap do |filters| [ "label=service=#{config.service}" ].tap do |filters|
filters << "label=destination=#{config.destination}" if config.destination filters << "label=destination=#{config.destination}" if config.destination

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,7 +71,7 @@ module Kamal::Commands
end end
def shell(command) def shell(command)
[ :sh, "-c", "'#{command.flatten.join(" ").gsub("'", "'\\''")}'" ] [ :sh, "-c", "'#{command.flatten.join(" ").gsub("'", "'\\\\''")}'" ]
end end
def docker(*args) def docker(*args)

View File

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

View File

@@ -71,7 +71,9 @@ class Kamal::Configuration
def roles def roles
@roles ||= role_names.collect { |role_name| Role.new(role_name, config: self) } @roles ||= role_names.collect do |role_name|
Role.new(role_name, config: self, specializations: role_specializations(role_name), primary: role_name == primary_role_name)
end
end end
def role(name) def role(name)
@@ -88,7 +90,7 @@ class Kamal::Configuration
def all_hosts def all_hosts
roles.flat_map(&:hosts).uniq (roles + accessories).flat_map(&:hosts).uniq
end end
def primary_host def primary_host
@@ -128,7 +130,11 @@ class Kamal::Configuration
end end
def latest_image def latest_image
"#{repository}:latest" "#{repository}:#{latest_tag}"
end
def latest_tag
[ "latest", *destination ].join("-")
end end
def service_with_version def service_with_version
@@ -222,7 +228,7 @@ class Kamal::Configuration
def host_env_directory def host_env_directory
"#{run_directory}/env" File.join(run_directory, "env")
end end
def env def env
@@ -297,7 +303,7 @@ class Kamal::Configuration
end end
def ensure_valid_service_name def ensure_valid_service_name
raise ArgumentError, "Service name can only include alphanumeric characters, hyphens, and underscores" unless raw_config[:service] =~ /^[a-z0-9_-]+$/ raise ArgumentError, "Service name can only include alphanumeric characters, hyphens, and underscores" unless raw_config[:service] =~ /^[a-z0-9_-]+$/i
true true
end end
@@ -321,6 +327,14 @@ class Kamal::Configuration
raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
end end
def role_specializations(name)
if servers.is_a?(Array) || servers[name].is_a?(Array)
{}
else
servers[name].except("hosts")
end
end
def git_version def git_version
@git_version ||= @git_version ||=
if Kamal::Git.used? if Kamal::Git.used?

View File

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

View File

@@ -2,11 +2,11 @@ class Kamal::Configuration::Role
CORD_FILE = "cord" CORD_FILE = "cord"
delegate :argumentize, :optionize, to: Kamal::Utils delegate :argumentize, :optionize, to: Kamal::Utils
attr_accessor :name attr_reader :name
alias to_s name alias to_s name
def initialize(name, config:) def initialize(name, config:, specializations:, primary:)
@name, @config = name.inquiry, config @name, @config, @specializations, @primary = name.inquiry, config, specializations, primary
end end
def primary_host def primary_host
@@ -98,7 +98,7 @@ class Kamal::Configuration::Role
end end
def primary? def primary?
self == @config.primary_role @primary
end end
@@ -163,8 +163,14 @@ class Kamal::Configuration::Role
File.join config.run_directory, "assets", "volumes", container_name(version) File.join config.run_directory, "assets", "volumes", container_name(version)
end end
def previous_roles
previous_role_names.map do |role_name|
Kamal::Configuration::Role.new(role_name, config: config, specializations: specializations, primary: primary?)
end
end
private private
attr_accessor :config attr_reader :config, :specializations
def extract_hosts_from_config def extract_hosts_from_config
if config.servers.is_a?(Array) if config.servers.is_a?(Array)
@@ -207,14 +213,6 @@ class Kamal::Configuration::Role
end end
end end
def specializations
if config.servers.is_a?(Array) || config.servers[name].is_a?(Array)
{}
else
config.servers[name].except("hosts")
end
end
def specialized_env def specialized_env
Kamal::Configuration::Env.from_config config: specializations.fetch("env", {}) Kamal::Configuration::Env.from_config config: specializations.fetch("env", {})
end end
@@ -237,4 +235,8 @@ class Kamal::Configuration::Role
options options
end end
end end
def previous_role_names
specializations.fetch("previously", [])
end
end end

View File

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

View File

@@ -74,4 +74,8 @@ module Kamal::Utils
matches matches
end end
def stable_sort!(elements, &block)
elements.sort_by!.with_index { |element, index| [ block.call(element), index ] }
end
end end

View File

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

View File

@@ -23,7 +23,7 @@ class CliAppTest < CliTestCase
.returns("running") # health check .returns("running") # health check
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--filter", "status=running", "--filter", "status=restarting", "--latest", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .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 .returns("123") # old version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
@@ -75,7 +75,7 @@ class CliAppTest < CliTestCase
.returns("running") # health check .returns("running") # health check
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--filter", "status=running", "--filter", "status=restarting", "--latest", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .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 .returns("123").twice # old version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
@@ -100,14 +100,18 @@ class CliAppTest < CliTestCase
test "stop" do test "stop" do
run_command("stop").tap do |output| run_command("stop").tap do |output|
assert_match "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker stop", output assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop", output
end end
end end
test "stale_containers" do test "stale_containers" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("12345678\n87654321") .returns("12345678\n87654321\n")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("12345678\n")
run_command("stale_containers").tap do |output| run_command("stale_containers").tap do |output|
assert_match /Detected stale container for role web with version 87654321/, output assert_match /Detected stale container for role web with version 87654321/, output
@@ -117,7 +121,11 @@ class CliAppTest < CliTestCase
test "stop stale_containers" do test "stop stale_containers" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("12345678\n87654321") .returns("12345678\n87654321\n")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("12345678\n")
run_command("stale_containers", "--stop").tap do |output| run_command("stale_containers", "--stop").tap do |output|
assert_match /Stopping stale container for role web with version 87654321/, output assert_match /Stopping stale container for role web with version 87654321/, output
@@ -133,7 +141,7 @@ class CliAppTest < CliTestCase
test "remove" do test "remove" do
run_command("remove").tap do |output| run_command("remove").tap do |output|
assert_match /#{Regexp.escape("docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker stop")}/, output assert_match /#{Regexp.escape("sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop")}/, output
assert_match /#{Regexp.escape("docker container prune --force --filter label=service=app")}/, output assert_match /#{Regexp.escape("docker container prune --force --filter label=service=app")}/, output
assert_match /#{Regexp.escape("docker image prune --all --force --filter label=service=app")}/, output assert_match /#{Regexp.escape("docker image prune --all --force --filter label=service=app")}/, output
end end
@@ -165,7 +173,7 @@ class CliAppTest < CliTestCase
test "exec with reuse" do test "exec with reuse" do
run_command("exec", "--reuse", "ruby -v").tap do |output| run_command("exec", "--reuse", "ruby -v").tap do |output|
assert_match "docker ps --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done", output # Get current version assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output # Get current version
assert_match "docker exec app-web-999 ruby -v", output assert_match "docker exec app-web-999 ruby -v", output
end end
end end
@@ -184,7 +192,7 @@ class CliAppTest < CliTestCase
.with("ssh -t root@1.1.1.1 -p 22 'docker exec -it app-web-999 ruby -v'") .with("ssh -t root@1.1.1.1 -p 22 'docker exec -it app-web-999 ruby -v'")
run_command("exec", "-i", "--reuse", "ruby -v").tap do |output| run_command("exec", "-i", "--reuse", "ruby -v").tap do |output|
assert_match "Get current version of running container...", output assert_match "Get current version of running container...", output
assert_match "Running docker ps --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done on 1.1.1.1", output assert_match "Running /usr/bin/env sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done on 1.1.1.1", output
assert_match "Launching interactive command with version 999 via SSH from existing container on 1.1.1.1...", output assert_match "Launching interactive command with version 999 via SSH from existing container on 1.1.1.1...", output
end end
end end
@@ -203,28 +211,28 @@ class CliAppTest < CliTestCase
test "logs" do test "logs" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec) SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.1 'docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest| xargs docker logs --timestamps --tail 10 2>&1'") .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 "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --tail 100 2>&1", run_command("logs") 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")
end end
test "logs with follow" do test "logs with follow" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec) SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.1 -p 22 'docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --tail 10 --follow 2>&1'") .with("ssh -t root@1.1.1.1 -p 22 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 10 --follow 2>&1'")
assert_match "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --tail 10 --follow 2>&1", run_command("logs", "--follow") assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 10 --follow 2>&1", run_command("logs", "--follow")
end end
test "version" do test "version" do
run_command("version").tap do |output| run_command("version").tap do |output|
assert_match "docker ps --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done", output assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output
end end
end end
test "version through main" do test "version through main" do
stdouted { Kamal::Cli::Main.start([ "app", "version", "-c", "test/fixtures/deploy_with_accessories.yml", "--hosts", "1.1.1.1" ]) }.tap do |output| stdouted { Kamal::Cli::Main.start([ "app", "version", "-c", "test/fixtures/deploy_with_accessories.yml", "--hosts", "1.1.1.1" ]) }.tap do |output|
assert_match "docker ps --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done", output assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output
end end
end end

View File

@@ -12,7 +12,7 @@ class CliBuildTest < CliTestCase
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "build", subcommand: "push" } hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "build", subcommand: "push" }
run_command("push").tap do |output| run_command("push", "--verbose").tap do |output|
assert_hook_ran "pre-build", output, **hook_variables assert_hook_ran "pre-build", output, **hook_variables
assert_match /docker --version && docker buildx version/, output assert_match /docker --version && docker buildx version/, output
assert_match /git archive -tar HEAD | docker buildx build --push --platform linux\/amd64,linux\/arm64 --builder kamal-app-multiarch -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile - as .*@localhost/, output assert_match /git archive -tar HEAD | docker buildx build --push --platform linux\/amd64,linux\/arm64 --builder kamal-app-multiarch -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile - as .*@localhost/, output
@@ -51,7 +51,8 @@ class CliBuildTest < CliTestCase
test "push pre-build hook failure" do test "push pre-build hook failure" do
fail_hook("pre-build") fail_hook("pre-build")
assert_raises(Kamal::Cli::HookError) { run_command("push") } error = assert_raises(Kamal::Cli::HookError) { run_command("push") }
assert_equal "Hook `pre-build` failed:\nfailed", error.message
assert @executions.none? { |args| args[0..2] == [ :docker, :buildx, :build ] } assert @executions.none? { |args| args[0..2] == [ :docker, :buildx, :build ] }
end end

View File

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

View File

@@ -46,7 +46,7 @@ class CliMainTest < CliTestCase
end end
test "deploy" do test "deploy" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, "verbose" => true }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options)
@@ -59,7 +59,7 @@ class CliMainTest < CliTestCase
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "deploy" } hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "deploy" }
run_command("deploy").tap do |output| run_command("deploy", "--verbose").tap do |output|
assert_hook_ran "pre-connect", output, **hook_variables assert_hook_ran "pre-connect", output, **hook_variables
assert_match /Log into image registry/, output assert_match /Log into image registry/, output
assert_match /Build and push app image/, output assert_match /Build and push app image/, output
@@ -68,7 +68,7 @@ class CliMainTest < CliTestCase
assert_match /Ensure app can pass healthcheck/, output assert_match /Ensure app can pass healthcheck/, output
assert_match /Detect stale containers/, output assert_match /Detect stale containers/, output
assert_match /Prune old containers and images/, output assert_match /Prune old containers and images/, output
assert_hook_ran "post-deploy", output, **hook_variables, runtime: 0 assert_hook_ran "post-deploy", output, **hook_variables, runtime: true
end end
end end
@@ -194,7 +194,7 @@ class CliMainTest < CliTestCase
end end
test "redeploy" do test "redeploy" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, "verbose" => true }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:healthcheck:perform", [], invoke_options)
@@ -205,13 +205,13 @@ class CliMainTest < CliTestCase
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "redeploy" } hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "redeploy" }
run_command("redeploy").tap do |output| run_command("redeploy", "--verbose").tap do |output|
assert_hook_ran "pre-connect", output, **hook_variables assert_hook_ran "pre-connect", output, **hook_variables
assert_match /Build and push app image/, output assert_match /Build and push app image/, output
assert_hook_ran "pre-deploy", output, **hook_variables assert_hook_ran "pre-deploy", output, **hook_variables
assert_match /Running the pre-deploy hook.../, output assert_match /Running the pre-deploy hook.../, output
assert_match /Ensure app can pass healthcheck/, output assert_match /Ensure app can pass healthcheck/, output
assert_hook_ran "post-deploy", output, **hook_variables, runtime: "0" assert_hook_ran "post-deploy", output, **hook_variables, runtime: true
end end
end end
@@ -250,7 +250,7 @@ class CliMainTest < CliTestCase
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet") .with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet")
.returns("version-to-rollback\n").at_least_once .returns("version-to-rollback\n").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=#{role}", "--filter", "status=running", "--filter", "status=restarting", "--latest", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-#{role}-}; done", raise_on_non_zero_exit: false) .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 .returns("version-to-rollback\n").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") .with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
@@ -268,12 +268,12 @@ class CliMainTest < CliTestCase
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 123, service_version: "app@123", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "rollback" } hook_variables = { version: 123, service_version: "app@123", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "rollback" }
run_command("rollback", "123", config_file: "deploy_with_accessories").tap do |output| run_command("rollback", "--verbose", "123", config_file: "deploy_with_accessories").tap do |output|
assert_hook_ran "pre-deploy", output, **hook_variables assert_hook_ran "pre-deploy", output, **hook_variables
assert_match "docker tag dhh/app:123 dhh/app:latest", output assert_match "docker tag dhh/app:123 dhh/app:latest", output
assert_match "docker run --detach --restart unless-stopped --name app-web-123", output assert_match "docker run --detach --restart unless-stopped --name app-web-123", output
assert_match "docker container ls --all --filter name=^app-web-version-to-rollback$ --quiet | xargs docker stop", output, "Should stop the container that was previously running" assert_match "docker container ls --all --filter name=^app-web-version-to-rollback$ --quiet | xargs docker stop", output, "Should stop the container that was previously running"
assert_hook_ran "post-deploy", output, **hook_variables, runtime: "0" assert_hook_ran "post-deploy", output, **hook_variables, runtime: true
end end
end end
@@ -286,7 +286,7 @@ class CliMainTest < CliTestCase
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", raise_on_non_zero_exit: false) .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", raise_on_non_zero_exit: false)
.returns("").at_least_once .returns("").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--filter", "status=running", "--filter", "status=restarting", "--latest", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .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 .returns("").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") .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}}'")

View File

@@ -119,9 +119,10 @@ class CommanderTest < ActiveSupport::TestCase
configure_with(:deploy_primary_web_role_override) configure_with(:deploy_primary_web_role_override)
@kamal.specific_roles = [ "web_*" ] @kamal.specific_roles = [ "web_*" ]
assert_equal [ "web_chicago", "web_tokyo" ], @kamal.roles.map(&:name) assert_equal [ "web_tokyo", "web_chicago" ], @kamal.roles.map(&:name)
assert_equal "web_tokyo", @kamal.primary_role.name assert_equal "web_tokyo", @kamal.primary_role.name
assert_equal "1.1.1.3", @kamal.primary_host assert_equal "1.1.1.3", @kamal.primary_host
assert_equal [ "1.1.1.3", "1.1.1.4", "1.1.1.1", "1.1.1.2" ], @kamal.hosts
end end
private private

View File

@@ -95,14 +95,14 @@ class CommandsAppTest < ActiveSupport::TestCase
test "stop" do test "stop" do
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker stop", "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(" ") new_command.stop.join(" ")
end end
test "stop with custom stop wait time" do test "stop with custom stop wait time" do
@config[:stop_wait_time] = 30 @config[:stop_wait_time] = 30
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | 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 -t 30",
new_command.stop.join(" ") new_command.stop.join(" ")
end end
@@ -128,45 +128,45 @@ class CommandsAppTest < ActiveSupport::TestCase
test "logs" do test "logs" do
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | 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 2>&1",
new_command.logs.join(" ") new_command.logs.join(" ")
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --since 5m 2>&1", "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",
new_command.logs(since: "5m").join(" ") new_command.logs(since: "5m").join(" ")
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --tail 100 2>&1", "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",
new_command.logs(lines: "100").join(" ") new_command.logs(lines: "100").join(" ")
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --since 5m --tail 100 2>&1", "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",
new_command.logs(since: "5m", lines: "100").join(" ") new_command.logs(since: "5m", lines: "100").join(" ")
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs 2>&1 | grep 'my-id'", "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'",
new_command.logs(grep: "my-id").join(" ") new_command.logs(grep: "my-id").join(" ")
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --since 5m 2>&1 | grep 'my-id'", "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'",
new_command.logs(since: "5m", grep: "my-id").join(" ") new_command.logs(since: "5m", grep: "my-id").join(" ")
end end
test "follow logs" do test "follow logs" do
assert_match \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --follow 2>&1", "ssh -t root@app-1 -p 22 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --follow 2>&1'",
new_command.follow_logs(host: "app-1") new_command.follow_logs(host: "app-1")
assert_match \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --follow 2>&1 | grep \"Completed\"", "ssh -t root@app-1 -p 22 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"Completed\"'",
new_command.follow_logs(host: "app-1", grep: "Completed") new_command.follow_logs(host: "app-1", grep: "Completed")
assert_match \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --tail 123 --follow 2>&1", "ssh -t root@app-1 -p 22 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1'",
new_command.follow_logs(host: "app-1", lines: 123) new_command.follow_logs(host: "app-1", lines: 123)
assert_match \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --tail 123 --follow 2>&1 | grep \"Completed\"", "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") new_command.follow_logs(host: "app-1", lines: 123, grep: "Completed")
end end
@@ -174,36 +174,48 @@ class CommandsAppTest < ActiveSupport::TestCase
test "execute in new container" do test "execute in new container" do
assert_equal \ assert_equal \
"docker run --rm --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails db:setup", "docker run --rm --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails db:setup",
new_command.execute_in_new_container("bin/rails", "db:setup").join(" ") new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ")
end
test "execute in new container with env" do
assert_equal \
"docker run --rm --env-file .kamal/env/roles/app-web.env --env foo=\"bar\" dhh/app:999 bin/rails db:setup",
new_command.execute_in_new_container("bin/rails", "db:setup", env: { "foo" => "bar" }).join(" ")
end end
test "execute in new container with custom options" do test "execute in new container with custom options" do
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } }
assert_equal \ assert_equal \
"docker run --rm --env-file .kamal/env/roles/app-web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails db:setup", "docker run --rm --env-file .kamal/env/roles/app-web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails db:setup",
new_command.execute_in_new_container("bin/rails", "db:setup").join(" ") new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ")
end end
test "execute in existing container" do test "execute in existing container" do
assert_equal \ assert_equal \
"docker exec app-web-999 bin/rails db:setup", "docker exec app-web-999 bin/rails db:setup",
new_command.execute_in_existing_container("bin/rails", "db:setup").join(" ") new_command.execute_in_existing_container("bin/rails", "db:setup", env: {}).join(" ")
end
test "execute in existing container with env" do
assert_equal \
"docker exec --env foo=\"bar\" app-web-999 bin/rails db:setup",
new_command.execute_in_existing_container("bin/rails", "db:setup", env: { "foo" => "bar" }).join(" ")
end end
test "execute in new container over ssh" do test "execute in new container over ssh" do
assert_match %r{docker run -it --rm --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails c}, assert_match %r{docker run -it --rm --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails c},
new_command.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1") new_command.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1", env: {})
end end
test "execute in new container with custom options over ssh" do test "execute in new container with custom options over ssh" do
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } }
assert_match %r{docker run -it --rm --env-file .kamal/env/roles/app-web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails c}, assert_match %r{docker run -it --rm --env-file .kamal/env/roles/app-web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails c},
new_command.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1") new_command.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1", env: {})
end end
test "execute in existing container over ssh" do test "execute in existing container over ssh" do
assert_match %r{docker exec -it app-web-999 bin/rails c}, assert_match %r{docker exec -it app-web-999 bin/rails c},
new_command.execute_in_existing_container_over_ssh("bin/rails", "c", host: "app-1") new_command.execute_in_existing_container_over_ssh("bin/rails", "c", host: "app-1", env: {})
end end
test "run over ssh" do test "run over ssh" do
@@ -242,14 +254,14 @@ class CommandsAppTest < ActiveSupport::TestCase
test "current_running_container_id" do test "current_running_container_id" do
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest", "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",
new_command.current_running_container_id.join(" ") new_command.current_running_container_id.join(" ")
end end
test "current_running_container_id with destination" do test "current_running_container_id with destination" do
@destination = "staging" @destination = "staging"
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=destination=staging --filter label=role=web --filter status=running --filter status=restarting --latest", "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination=staging --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest-staging --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination=staging --filter label=role=web --filter status=running --filter status=restarting' | head -1",
new_command.current_running_container_id.join(" ") new_command.current_running_container_id.join(" ")
end end
@@ -261,7 +273,7 @@ class CommandsAppTest < ActiveSupport::TestCase
test "current_running_version" do test "current_running_version" do
assert_equal \ assert_equal \
"docker ps --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done", "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",
new_command.current_running_version.join(" ") new_command.current_running_version.join(" ")
end end
@@ -339,10 +351,17 @@ class CommandsAppTest < ActiveSupport::TestCase
new_command.remove_images.join(" ") new_command.remove_images.join(" ")
end end
test "tag_current_image_as_latest" do test "tag_latest_image" do
assert_equal \ assert_equal \
"docker tag dhh/app:999 dhh/app:latest", "docker tag dhh/app:999 dhh/app:latest",
new_command.tag_current_image_as_latest.join(" ") new_command.tag_latest_image.join(" ")
end
test "tag_latest_image with destination" do
@destination = "staging"
assert_equal \
"docker tag dhh/app:999 dhh/app:latest-staging",
new_command.tag_latest_image.join(" ")
end end
test "make_env_directory" do test "make_env_directory" do
@@ -371,7 +390,7 @@ class CommandsAppTest < ActiveSupport::TestCase
assert_equal [ assert_equal [
:mkdir, "-p", ".kamal/assets/extracted/app-web-999", "&&", :mkdir, "-p", ".kamal/assets/extracted/app-web-999", "&&",
:docker, :stop, "-t 1", "app-web-assets", "2> /dev/null", "|| true", "&&", :docker, :stop, "-t 1", "app-web-assets", "2> /dev/null", "|| true", "&&",
:docker, :run, "--name", "app-web-assets", "--detach", "--rm", "dhh/app:latest", "sleep 1000000", "&&", :docker, :run, "--name", "app-web-assets", "--detach", "--rm", "dhh/app:999", "sleep 1000000", "&&",
:docker, :cp, "-L", "app-web-assets:/public/assets/.", ".kamal/assets/extracted/app-web-999", "&&", :docker, :cp, "-L", "app-web-assets:/public/assets/.", ".kamal/assets/extracted/app-web-999", "&&",
:docker, :stop, "-t 1", "app-web-assets" :docker, :stop, "-t 1", "app-web-assets"
], new_command(asset_path: "/public/assets").extract_assets ], new_command(asset_path: "/public/assets").extract_assets

View File

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

View File

@@ -53,7 +53,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
test "custom labels via role specialization" do test "custom labels via role specialization" do
@deploy_with_roles[:labels] = { "my.custom.label" => "50" } @deploy_with_roles[:labels] = { "my.custom.label" => "50" }
@deploy_with_roles[:servers]["workers"]["labels"] = { "my.custom.label" => "70" } @deploy_with_roles[:servers]["workers"]["labels"] = { "my.custom.label" => "70" }
assert_equal "70", @config_with_roles.role(:workers).labels["my.custom.label"] config_with_roles = Kamal::Configuration.new(@deploy_with_roles)
assert_equal "70", config_with_roles.role(:workers).labels["my.custom.label"]
end end
test "overwriting default traefik label" do test "overwriting default traefik label" do
@@ -109,6 +110,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
] ]
} }
config_with_roles = Kamal::Configuration.new(@deploy_with_roles)
ENV["REDIS_PASSWORD"] = "secret456" ENV["REDIS_PASSWORD"] = "secret456"
ENV["DB_PASSWORD"] = "secret&\"123" ENV["DB_PASSWORD"] = "secret&\"123"
@@ -117,8 +120,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
DB_PASSWORD=secret&\"123 DB_PASSWORD=secret&\"123
ENV ENV
assert_equal expected_secrets_file, @config_with_roles.role(:workers).env.secrets_io.string assert_equal expected_secrets_file, config_with_roles.role(:workers).env.secrets_io.string
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], config_with_roles.role(:workers).env_args
ensure ensure
ENV["REDIS_PASSWORD"] = nil ENV["REDIS_PASSWORD"] = nil
ENV["DB_PASSWORD"] = nil ENV["DB_PASSWORD"] = nil
@@ -135,14 +138,16 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
] ]
} }
config_with_roles = Kamal::Configuration.new(@deploy_with_roles)
ENV["DB_PASSWORD"] = "secret123" ENV["DB_PASSWORD"] = "secret123"
expected_secrets_file = <<~ENV expected_secrets_file = <<~ENV
DB_PASSWORD=secret123 DB_PASSWORD=secret123
ENV ENV
assert_equal expected_secrets_file, @config_with_roles.role(:workers).env.secrets_io.string assert_equal expected_secrets_file, config_with_roles.role(:workers).env.secrets_io.string
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], config_with_roles.role(:workers).env_args
ensure ensure
ENV["DB_PASSWORD"] = nil ENV["DB_PASSWORD"] = nil
end end
@@ -185,14 +190,16 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
} }
} }
config_with_roles = Kamal::Configuration.new(@deploy_with_roles)
ENV["REDIS_PASSWORD"] = "secret456" ENV["REDIS_PASSWORD"] = "secret456"
expected_secrets_file = <<~ENV expected_secrets_file = <<~ENV
REDIS_PASSWORD=secret456 REDIS_PASSWORD=secret456
ENV ENV
assert_equal expected_secrets_file, @config_with_roles.role(:workers).env.secrets_io.string assert_equal expected_secrets_file, config_with_roles.role(:workers).env.secrets_io.string
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://c/d\"" ], @config_with_roles.role(:workers).env_args assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://c/d\"" ], config_with_roles.role(:workers).env_args
ensure ensure
ENV["REDIS_PASSWORD"] = nil ENV["REDIS_PASSWORD"] = nil
end end

View File

@@ -44,6 +44,7 @@ class ConfigurationTest < ActiveSupport::TestCase
test "service name valid" do test "service name valid" do
assert Kamal::Configuration.new(@deploy.tap { _1[:service] = "hey-app1_primary" }).valid? assert Kamal::Configuration.new(@deploy.tap { _1[:service] = "hey-app1_primary" }).valid?
assert Kamal::Configuration.new(@deploy.tap { _1[:service] = "MyApp" }).valid?
end end
test "service name invalid" do test "service name invalid" do

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
service: app
image: app
primary_role: app
servers:
app:
previously:
- web
hosts:
- vm1
- vm2
jobs:
previously:
- workers
hosts:
- vm3
cmd: sleep infinity
asset_path: /usr/share/nginx/html/versions
registry:
server: registry:4443
username: root
password: root
builder:
multiarch: false
args:
COMMIT_SHA: <%= `git rev-parse HEAD` %>
healthcheck:
cmd: wget -qO- http://localhost > /dev/null || exit 1
traefik:
args:
accesslog: true
accesslog.format: json
image: registry:4443/traefik:v2.10
accessories:
busybox:
service: custom-busybox
image: registry:4443/busybox:1.36.0
cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done'
roles:
- app
stop_wait_time: 1

View File

@@ -0,0 +1,3 @@
#!/bin/bash
cd $1 && cp -f config/deploy_renamed_roles.yml config/deploy.yml && git commit -am 'Rename roles'

View File

@@ -131,4 +131,16 @@ class IntegrationTest < ActiveSupport::TestCase
puts "Tried to get the response code again and got #{app_response.code}" puts "Tried to get the response code again and got #{app_response.code}"
end end
end end
def assert_container_running(host:, name:)
assert container_running?(host: host, name: name)
end
def assert_container_not_running(host:, name:)
assert_not container_running?(host: host, name: name)
end
def container_running?(host:, name:)
docker_compose("exec #{host} docker ps --filter=name=#{name} | tail -n+2", capture: true).strip.present?
end
end end

View File

@@ -3,8 +3,8 @@ require_relative "integration_test"
class MainTest < IntegrationTest class MainTest < IntegrationTest
test "envify, deploy, redeploy, rollback, details and audit" do test "envify, deploy, redeploy, rollback, details and audit" do
kamal :envify kamal :envify
assert_local_env_file "SECRET_TOKEN=1234" assert_local_env_file "SECRET_TOKEN='1234 with \"中文\"'"
assert_remote_env_file "SECRET_TOKEN=1234" assert_remote_env_file "SECRET_TOKEN=1234 with \"中文\""
remove_local_env_file remove_local_env_file
first_version = latest_app_version first_version = latest_app_version
@@ -16,7 +16,7 @@ class MainTest < IntegrationTest
assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy" assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy"
assert_env :CLEAR_TOKEN, "4321", version: first_version assert_env :CLEAR_TOKEN, "4321", version: first_version
assert_env :HOST_TOKEN, "abcd", version: first_version assert_env :HOST_TOKEN, "abcd", version: first_version
assert_env :SECRET_TOKEN, "1234", version: first_version assert_env :SECRET_TOKEN, "1234 with \"中文\"", version: first_version
second_version = update_app_rev second_version = update_app_rev
@@ -92,6 +92,33 @@ class MainTest < IntegrationTest
assert_no_images_or_containers assert_no_images_or_containers
end end
test "rename roles" do
@app = "app_with_roles"
kamal :envify
kamal :deploy
first_version = latest_app_version
assert_container_running host: :vm1, name: "app-web-#{first_version}"
assert_container_running host: :vm2, name: "app-web-#{first_version}"
assert_container_running host: :vm3, name: "app-workers-#{first_version}"
rename_roles
kamal :envify
kamal :deploy
second_version = latest_app_version
assert_container_running host: :vm1, name: "app-app-#{second_version}"
assert_container_running host: :vm2, name: "app-app-#{second_version}"
assert_container_running host: :vm3, name: "app-jobs-#{second_version}"
assert_container_not_running host: :vm1, name: "app-web-#{first_version}"
assert_container_not_running host: :vm2, name: "app-web-#{first_version}"
assert_container_not_running host: :vm3, name: "app-workers-#{first_version}"
end
private private
def assert_local_env_file(contents) def assert_local_env_file(contents)
assert_equal contents, deployer_exec("cat .env", capture: true) assert_equal contents, deployer_exec("cat .env", capture: true)
@@ -139,7 +166,7 @@ class MainTest < IntegrationTest
assert vm1_container_ids.any? assert vm1_container_ids.any?
end end
def assert_container_running(host:, name:) def rename_roles
assert docker_compose("exec #{host} docker ps --filter=name=#{name} -q", capture: true).strip.present? deployer_exec "./rename_roles.sh #{@app}", workdir: "/"
end end
end end

View File

@@ -7,13 +7,13 @@ class TraefikTest < IntegrationTest
kamal :traefik, :boot kamal :traefik, :boot
assert_traefik_running assert_traefik_running
output = kamal :traefik, :reboot, "-y", capture: true output = kamal :traefik, :reboot, "-y", "--verbose", capture: true
assert_traefik_running assert_traefik_running
assert_hooks_ran "pre-traefik-reboot", "post-traefik-reboot" assert_hooks_ran "pre-traefik-reboot", "post-traefik-reboot"
assert_match /Rebooting Traefik on vm1,vm2.../, output assert_match /Rebooting Traefik on vm1,vm2.../, output
assert_match /Rebooted Traefik on vm1,vm2/, output assert_match /Rebooted Traefik on vm1,vm2/, output
output = kamal :traefik, :reboot, :"--rolling", "-y", capture: true output = kamal :traefik, :reboot, "--rolling", "-y", "--verbose", capture: true
assert_traefik_running assert_traefik_running
assert_hooks_ran "pre-traefik-reboot", "post-traefik-reboot" assert_hooks_ran "pre-traefik-reboot", "post-traefik-reboot"
assert_match /Rebooting Traefik on vm1.../, output assert_match /Rebooting Traefik on vm1.../, output