Catch up with 2.5.1

This commit is contained in:
Nick Hammond
2025-02-06 07:12:05 -07:00
34 changed files with 370 additions and 159 deletions

View File

@@ -23,6 +23,7 @@ jobs:
run: bundle exec rubocop --parallel
tests:
strategy:
fail-fast: false
matrix:
ruby-version:
- "3.1"
@@ -37,7 +38,6 @@ jobs:
gemfile: gemfiles/rails_edge.gemfile
name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }}
runs-on: ubuntu-latest
continue-on-error: true
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
steps:

View File

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

View File

@@ -1,6 +1,7 @@
class Kamal::Cli::Alias::Command < Thor::DynamicCommand
def run(instance, args = [])
if (_alias = KAMAL.config.aliases[name])
KAMAL.reset
Kamal::Cli::Main.start(Shellwords.split(_alias.command) + ARGV[1..-1])
else
super

View File

@@ -16,10 +16,18 @@ class Kamal::Cli::App < Kamal::Cli::Base
# Primary hosts and roles are returned first, so they can open the barrier
barrier = Kamal::Cli::Healthcheck::Barrier.new
on(KAMAL.hosts, **KAMAL.boot_strategy) do |host|
KAMAL.roles_on(host).each do |role|
Kamal::Cli::App::Boot.new(host, role, self, version, barrier).run
host_boot_groups.each do |hosts|
host_list = Array(hosts).join(",")
run_hook "pre-app-boot", hosts: host_list
on(hosts) do |host|
KAMAL.roles_on(host).each do |role|
Kamal::Cli::App::Boot.new(host, role, self, version, barrier).run
end
end
run_hook "post-app-boot", hosts: host_list
sleep KAMAL.config.boot.wait if KAMAL.config.boot.wait
end
# Tag once the app booted on all hosts
@@ -340,4 +348,8 @@ class Kamal::Cli::App < Kamal::Cli::Base
yield
end
end
def host_boot_groups
KAMAL.config.boot.limit ? KAMAL.hosts.each_slice(KAMAL.config.boot.limit).to_a : [ KAMAL.hosts ]
end
end

View File

@@ -31,7 +31,7 @@ module Kamal::Cli
super
end
initialize_commander unless config[:invoked_via_subcommand]
initialize_commander unless KAMAL.configured?
end
private

View File

@@ -5,11 +5,12 @@ class Kamal::Cli::Build < Kamal::Cli::Base
desc "deliver", "Build app and push app image to registry then pull image on servers"
def deliver
push
pull
invoke :push
invoke :pull
end
desc "push", "Build and push app image to registry"
option :output, type: :string, default: "registry", banner: "export_type", desc: "Exported type for the build result, and may be any exported type supported by 'buildx --output'."
def push
cli = self
@@ -49,7 +50,7 @@ class Kamal::Cli::Build < Kamal::Cli::Base
end
# Get the command here to ensure the Dir.chdir doesn't interfere with it
push = KAMAL.builder.push
push = KAMAL.builder.push(cli.options[:output])
KAMAL.with_verbosity(:debug) do
Dir.chdir(KAMAL.config.builder.build_directory) { execute *push }
@@ -108,6 +109,41 @@ class Kamal::Cli::Build < Kamal::Cli::Base
end
end
desc "dev", "Build using the working directory, tag it as dirty, and push to local image store."
option :output, type: :string, default: "docker", banner: "export_type", desc: "Exported type for the build result, and may be any exported type supported by 'buildx --output'."
def dev
cli = self
ensure_docker_installed
docker_included_files = Set.new(Kamal::Docker.included_files)
git_uncommitted_files = Set.new(Kamal::Git.uncommitted_files)
git_untracked_files = Set.new(Kamal::Git.untracked_files)
docker_uncommitted_files = docker_included_files & git_uncommitted_files
if docker_uncommitted_files.any?
say "WARNING: Files with uncommitted changes will be present in the dev container:", :yellow
docker_uncommitted_files.sort.each { |f| say " #{f}", :yellow }
say
end
docker_untracked_files = docker_included_files & git_untracked_files
if docker_untracked_files.any?
say "WARNING: Untracked files will be present in the dev container:", :yellow
docker_untracked_files.sort.each { |f| say " #{f}", :yellow }
say
end
with_env(KAMAL.config.builder.secrets) do
run_locally do
build = KAMAL.builder.push(cli.options[:output], tag_as_dirty: true)
KAMAL.with_verbosity(:debug) do
execute(*build)
end
end
end
end
private
def connect_to_remote_host(remote_host)
remote_uri = URI.parse(remote_host)

View File

@@ -3,7 +3,7 @@ class Kamal::Cli::Registry < Kamal::Cli::Base
option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login"
option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login"
def login
ensure_docker_installed
ensure_docker_installed unless options[:skip_local]
run_locally { execute *KAMAL.registry.login } unless options[:skip_local]
on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote]

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..."

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..."

View File

@@ -4,13 +4,20 @@ require "active_support/core_ext/object/blank"
class Kamal::Commander
attr_accessor :verbosity, :holding_lock, :connected
attr_reader :specific_roles, :specific_hosts
delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :proxy_hosts, :accessory_hosts, to: :specifics
def initialize
reset
end
def reset
self.verbosity = :info
self.holding_lock = false
self.connected = false
@specifics = nil
@specifics = @specific_roles = @specific_hosts = nil
@config = @config_kwargs = nil
@commands = {}
end
def config
@@ -28,8 +35,6 @@ class Kamal::Commander
@config || @config_kwargs
end
attr_reader :specific_roles, :specific_hosts
def specific_primary!
@specifics = nil
if specific_roles.present?
@@ -89,35 +94,35 @@ class Kamal::Commander
end
def builder
@builder ||= Kamal::Commands::Builder.new(config)
@commands[:builder] ||= Kamal::Commands::Builder.new(config)
end
def docker
@docker ||= Kamal::Commands::Docker.new(config)
@commands[:docker] ||= Kamal::Commands::Docker.new(config)
end
def hook
@hook ||= Kamal::Commands::Hook.new(config)
@commands[:hook] ||= Kamal::Commands::Hook.new(config)
end
def lock
@lock ||= Kamal::Commands::Lock.new(config)
@commands[:lock] ||= Kamal::Commands::Lock.new(config)
end
def proxy
@proxy ||= Kamal::Commands::Proxy.new(config)
@commands[:proxy] ||= Kamal::Commands::Proxy.new(config)
end
def prune
@prune ||= Kamal::Commands::Prune.new(config)
@commands[:prune] ||= Kamal::Commands::Prune.new(config)
end
def registry
@registry ||= Kamal::Commands::Registry.new(config)
@commands[:registry] ||= Kamal::Commands::Registry.new(config)
end
def server
@server ||= Kamal::Commands::Server.new(config)
@commands[:server] ||= Kamal::Commands::Server.new(config)
end
def alias(name)
@@ -136,14 +141,6 @@ class Kamal::Commander
SSHKit.config.output_verbosity = old_level
end
def boot_strategy
if config.boot.limit.present?
{ in: :groups, limit: config.boot.limit, wait: config.boot.wait }
else
{}
end
end
def holding_lock?
self.holding_lock
end

View File

@@ -1,7 +1,7 @@
require "active_support/core_ext/string/filters"
class Kamal::Commands::Builder < Kamal::Commands::Base
delegate :create, :remove, :push, :clean, :pull, :info, :inspect_builder, :validate_image, :first_mirror, to: :target
delegate :create, :remove, :dev, :push, :clean, :pull, :info, :inspect_builder, :validate_image, :first_mirror, to: :target
delegate :local?, :remote?, :pack?, :cloud?, to: "config.builder"
include Clone

View File

@@ -14,11 +14,12 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
docker :image, :rm, "--force", config.absolute_image
end
def push
def push(export_action = "registry", tag_as_dirty: false)
docker :buildx, :build,
"--push",
"--output=type=#{export_action}",
*platform_options(arches),
*([ "--builder", builder_name ] unless docker_driver?),
*build_tag_options(tag_as_dirty: tag_as_dirty),
*build_options,
build_context
end
@@ -38,7 +39,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
end
def build_options
[ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh, *builder_provenance, *builder_sbom ]
[ *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh, *builder_provenance, *builder_sbom ]
end
def build_context
@@ -59,8 +60,14 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
end
private
def build_tags
[ "-t", config.absolute_image, "-t", config.latest_image ]
def build_tag_names(tag_as_dirty: false)
tag_names = [ config.absolute_image, config.latest_image ]
tag_names.map! { |t| "#{t}-dirty" } if tag_as_dirty
tag_names
end
def build_tag_options(tag_as_dirty: false)
build_tag_names(tag_as_dirty: tag_as_dirty).flat_map { |name| [ "-t", name ] }
end
def build_cache

View File

@@ -16,6 +16,8 @@ class Kamal::Configuration::Accessory
context: "accessories/#{name}",
with: Kamal::Configuration::Validator::Accessory
ensure_valid_roles
@env = initialize_env
@proxy = initialize_proxy if running_proxy?
@registry = initialize_registry if accessory_config["registry"].present?
@@ -200,13 +202,17 @@ class Kamal::Configuration::Accessory
def hosts_from_roles
if accessory_config.key?("roles")
accessory_config["roles"].flat_map do |role|
config.role(role)&.hosts || raise(Kamal::ConfigurationError, "Unknown role in accessories config: '#{role}'")
end
accessory_config["roles"].flat_map { |role| config.role(role)&.hosts }
end
end
def network
accessory_config["network"] || DEFAULT_NETWORK
end
def ensure_valid_roles
if accessory_config["roles"] && (missing_roles = accessory_config["roles"] - config.roles.map(&:name)).any?
raise Kamal::ConfigurationError, "accessories/#{name}: unknown roles #{missing_roles.join(", ")}"
end
end
end

View File

@@ -118,5 +118,6 @@ accessories:
# Proxy
#
# You can run your accessory behind the Kamal proxy. See kamal docs proxy for more information
proxy:
...

30
lib/kamal/docker.rb Normal file
View File

@@ -0,0 +1,30 @@
require "tempfile"
require "open3"
module Kamal::Docker
extend self
BUILD_CHECK_TAG = "kamal-local-build-check"
def included_files
Tempfile.create do |dockerfile|
dockerfile.write(<<~DOCKERFILE)
FROM busybox
COPY . app
WORKDIR app
CMD find . -type f | sed "s|^\./||"
DOCKERFILE
dockerfile.close
cmd = "docker buildx build -t=#{BUILD_CHECK_TAG} -f=#{dockerfile.path} ."
system(cmd) || raise("failed to build check image")
end
cmd = "docker run --rm #{BUILD_CHECK_TAG}"
out, err, status = Open3.capture3(cmd)
unless status
raise "failed to run check image:\n#{err}"
end
out.lines.map(&:strip)
end
end

View File

@@ -24,4 +24,14 @@ module Kamal::Git
def root
`git rev-parse --show-toplevel`.strip
end
# returns an array of relative path names of files with uncommitted changes
def uncommitted_files
`git ls-files --modified`.lines.map(&:strip)
end
# returns an array of relative path names of untracked files, including gitignored files
def untracked_files
`git ls-files --others`.lines.map(&:strip)
end
end

View File

@@ -1,10 +1,14 @@
class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Base
def requires_account?
false
end
private
def login(_account)
nil
end
def fetch_secrets(secrets, from:, account:, session:)
def fetch_secrets(secrets, from:, account: nil, session:)
{}.tap do |results|
get_from_secrets_manager(prefixed_secrets(secrets, from: from), account: account).each do |secret|
secret_name = secret["Name"]
@@ -19,8 +23,12 @@ class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Ba
end
end
def get_from_secrets_manager(secrets, account:)
`aws secretsmanager batch-get-secret-value --secret-id-list #{secrets.map(&:shellescape).join(" ")} --profile #{account.shellescape}`.tap do |secrets|
def get_from_secrets_manager(secrets, account: nil)
args = [ "aws", "secretsmanager", "batch-get-secret-value", "--secret-id-list" ] + secrets.map(&:shellescape)
args += [ "--profile", account.shellescape ] if account
cmd = args.join(" ")
`#{cmd}`.tap do |secrets|
raise RuntimeError, "Could not read #{secrets} from AWS Secrets Manager" unless $?.success?
secrets = JSON.parse(secrets)

View File

@@ -1,3 +1,3 @@
module Kamal
VERSION = "2.4.0"
VERSION = "2.5.1"
end

View File

@@ -37,13 +37,20 @@ class CliAppTest < CliTestCase
end
test "boot uses group strategy when specified" do
Kamal::Cli::App.any_instance.stubs(:on).with("1.1.1.1").times(2) # ensure locks dir, acquire & release lock
Kamal::Cli::App.any_instance.stubs(:on).with([ "1.1.1.1" ]) # tag container
Kamal::Cli::App.any_instance.stubs(:on).with("1.1.1.1").twice
Kamal::Cli::App.any_instance.stubs(:on).with([ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ]).times(3)
# Strategy is used when booting the containers
Kamal::Cli::App.any_instance.expects(:on).with([ "1.1.1.1" ], in: :groups, limit: 3, wait: 2).with_block_given
Kamal::Cli::App.any_instance.expects(:on).with([ "1.1.1.1", "1.1.1.2", "1.1.1.3" ]).with_block_given
Kamal::Cli::App.any_instance.expects(:on).with([ "1.1.1.4" ]).with_block_given
Object.any_instance.expects(:sleep).with(2).twice
run_command("boot", config: :with_boot_strategy)
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
run_command("boot", config: :with_boot_strategy, host: nil).tap do |output|
assert_hook_ran "pre-app-boot", output, count: 2
assert_hook_ran "post-app-boot", output, count: 2
end
end
test "boot errors don't leave lock in place" do

View File

@@ -11,7 +11,6 @@ class CliBuildTest < CliTestCase
test "push" do
with_build_directory do |build_directory|
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "build", subcommand: "push" }
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD)
@@ -22,11 +21,33 @@ class CliBuildTest < CliTestCase
.returns("")
run_command("push", "--verbose").tap do |output|
assert_hook_ran "pre-build", output, **hook_variables
assert_hook_ran "pre-build", output
assert_match /Cloning repo into build directory/, output
assert_match /git -C #{Dir.tmpdir}\/kamal-clones\/app-#{pwd_sha} clone #{Dir.pwd}/, output
assert_match /docker --version && docker buildx version/, output
assert_match /docker buildx build --push --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. as .*@localhost/, output
assert_match /docker buildx build --output=type=registry --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. as .*@localhost/, output
end
end
end
test "push --output=docker" do
with_build_directory do |build_directory|
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD)
.returns(Kamal::Git.revision)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :status, "--porcelain")
.returns("")
run_command("push", "--output=docker", "--verbose").tap do |output|
assert_hook_ran "pre-build", output
assert_match /Cloning repo into build directory/, output
assert_match /git -C #{Dir.tmpdir}\/kamal-clones\/app-#{pwd_sha} clone #{Dir.pwd}/, output
assert_match /docker --version && docker buildx version/, output
assert_match /docker buildx build --output=type=docker --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. as .*@localhost/, output
end
end
end
@@ -49,7 +70,7 @@ class CliBuildTest < CliTestCase
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :submodule, :update, "--init")
SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".")
.with(:docker, :buildx, :build, "--output=type=registry", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD)
@@ -68,13 +89,12 @@ class CliBuildTest < CliTestCase
test "push without clone" do
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "build", subcommand: "push" }
run_command("push", "--verbose", fixture: :without_clone).tap do |output|
assert_no_match /Cloning repo into build directory/, output
assert_hook_ran "pre-build", output, **hook_variables
assert_hook_ran "pre-build", output
assert_match /docker --version && docker buildx version/, output
assert_match /docker buildx build --push --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile . as .*@localhost/, output
assert_match /docker buildx build --output=type=registry --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile . as .*@localhost/, output
end
end
@@ -140,7 +160,7 @@ class CliBuildTest < CliTestCase
.returns("")
SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".")
.with(:docker, :buildx, :build, "--output=type=registry", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".")
run_command("push").tap do |output|
assert_match /WARN Missing compatible builder, so creating a new one first/, output
@@ -275,6 +295,30 @@ class CliBuildTest < CliTestCase
end
end
test "dev" do
with_build_directory do |build_directory|
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
run_command("dev", "--verbose").tap do |output|
assert_no_match(/Cloning repo into build directory/, output)
assert_match(/docker --version && docker buildx version/, output)
assert_match(/docker buildx build --output=type=docker --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999-dirty -t dhh\/app:latest-dirty --label service="app" --file Dockerfile \. as .*@localhost/, output)
end
end
end
test "dev --output=local" do
with_build_directory do |build_directory|
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
run_command("dev", "--output=local", "--verbose").tap do |output|
assert_no_match(/Cloning repo into build directory/, output)
assert_match(/docker --version && docker buildx version/, output)
assert_match(/docker buildx build --output=type=local --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999-dirty -t dhh\/app:latest-dirty --label service="app" --file Dockerfile \. as .*@localhost/, output)
end
end
end
private
def run_command(*command, fixture: :with_accessories)
stdouted { Kamal::Cli::Build.start([ *command, "-c", "test/fixtures/deploy_#{fixture}.yml" ]) }

View File

@@ -40,8 +40,9 @@ class CliTestCase < ActiveSupport::TestCase
.with(:docker, :buildx, :inspect, "kamal-local-docker-container")
end
def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, subcommand: nil, runtime: false, secrets: false)
assert_match %r{usr/bin/env\s\.kamal/hooks/#{hook}}, output
def assert_hook_ran(hook, output, count: 1)
regexp = ([ "/usr/bin/env .kamal/hooks/#{hook}" ] * count).join(".*")
assert_match /#{regexp}/m, output
end
def with_argv(*argv)

View File

@@ -53,17 +53,16 @@ class CliMainTest < CliTestCase
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options)
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "deploy" }
run_command("deploy", "--verbose").tap do |output|
assert_hook_ran "pre-connect", output, **hook_variables
assert_hook_ran "pre-connect", output
assert_match /Log into image registry/, output
assert_match /Build and push app image/, output
assert_hook_ran "pre-deploy", output, **hook_variables, secrets: true
assert_hook_ran "pre-deploy", output
assert_match /Ensure kamal-proxy is running/, output
assert_match /Detect stale containers/, output
assert_match /Prune old containers and images/, output
assert_hook_ran "post-deploy", output, **hook_variables, runtime: true, secrets: true
assert_hook_ran "post-deploy", output
end
end
end
@@ -205,14 +204,12 @@ class CliMainTest < CliTestCase
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "redeploy" }
run_command("redeploy", "--verbose").tap do |output|
assert_hook_ran "pre-connect", output, **hook_variables
assert_hook_ran "pre-connect", output
assert_match /Build and push app image/, output
assert_hook_ran "pre-deploy", output, **hook_variables
assert_hook_ran "pre-deploy", output
assert_match /Running the pre-deploy hook.../, output
assert_hook_ran "post-deploy", output, **hook_variables, runtime: true
assert_hook_ran "post-deploy", output
end
end
@@ -258,14 +255,13 @@ class CliMainTest < CliTestCase
.returns("running").at_least_once # health check
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 123, service_version: "app@123", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "rollback" }
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
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 container ls --all --filter name=^app-web-version-to-rollback$ --quiet | xargs docker stop", output, "Should stop the container that was previously running"
assert_hook_ran "post-deploy", output, **hook_variables, runtime: true
assert_hook_ran "post-deploy", output
end
end

View File

@@ -52,6 +52,18 @@ class CliRegistryTest < CliTestCase
assert_raises(Kamal::Cli::DependencyError) { run_command("login") }
end
test "allow remote login with no docker" do
stub_setup
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, "--version", "&&", :docker, :buildx, "version")
.raises(SSHKit::Command::Failed.new("command not found"))
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args[0..1] == [ :docker, :login ] }
assert_nothing_raised { run_command("login", "--skip-local") }
end
private
def run_command(*command)

View File

@@ -104,28 +104,6 @@ class CommanderTest < ActiveSupport::TestCase
assert_equal [ "web", "workers" ], @kamal.roles_on("1.1.1.1").map(&:name)
end
test "default group strategy" do
assert_empty @kamal.boot_strategy
end
test "specific limit group strategy" do
configure_with(:deploy_with_boot_strategy)
assert_equal({ in: :groups, limit: 3, wait: 2 }, @kamal.boot_strategy)
end
test "percentage-based group strategy" do
configure_with(:deploy_with_percentage_boot_strategy)
assert_equal({ in: :groups, limit: 1, wait: 2 }, @kamal.boot_strategy)
end
test "percentage-based group strategy limit is at least 1" do
configure_with(:deploy_with_low_percentage_boot_strategy)
assert_equal({ in: :groups, limit: 1, wait: 2 }, @kamal.boot_strategy)
end
test "try to match the primary role from a list of specific roles" do
configure_with(:deploy_primary_web_role_override)

View File

@@ -9,7 +9,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "cache" => { "type" => "gha" } })
assert_equal "local", builder.name
assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
"docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ")
end
@@ -17,7 +17,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "arch" => [ "amd64" ] })
assert_equal "local", builder.name
assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .",
"docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .",
builder.push.join(" ")
end
@@ -25,7 +25,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "cache" => { "type" => "gha" } })
assert_equal "local", builder.name
assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
"docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ")
end
@@ -33,7 +33,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "arch" => [ "amd64", "arm64" ], "remote" => "ssh://app@127.0.0.1", "cache" => { "type" => "gha" } })
assert_equal "hybrid", builder.name
assert_equal \
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-hybrid-docker-container-ssh---app-127-0-0-1 -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
"docker buildx build --output=type=registry --platform linux/amd64,linux/arm64 --builder kamal-hybrid-docker-container-ssh---app-127-0-0-1 -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ")
end
@@ -41,7 +41,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "arch" => [ "amd64", "arm64" ], "remote" => "ssh://app@127.0.0.1", "cache" => { "type" => "gha" }, "local" => false })
assert_equal "remote", builder.name
assert_equal \
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-remote-ssh---app-127-0-0-1 -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
"docker buildx build --output=type=registry --platform linux/amd64,linux/arm64 --builder kamal-remote-ssh---app-127-0-0-1 -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ")
end
@@ -49,7 +49,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "arch" => [ "#{remote_arch}" ], "remote" => "ssh://app@host", "cache" => { "type" => "gha" } })
assert_equal "remote", builder.name
assert_equal \
"docker buildx build --push --platform linux/#{remote_arch} --builder kamal-remote-ssh---app-host -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
"docker buildx build --output=type=registry --platform linux/#{remote_arch} --builder kamal-remote-ssh---app-host -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ")
end
@@ -57,7 +57,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "arch" => [ "#{local_arch}" ], "remote" => "ssh://app@host", "cache" => { "type" => "gha" } })
assert_equal "local", builder.name
assert_equal \
"docker buildx build --push --platform linux/#{local_arch} --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
"docker buildx build --output=type=registry --platform linux/#{local_arch} --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ")
end
@@ -91,14 +91,14 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "arch" => [ "#{local_arch}" ], "driver" => "cloud docker-org-name/builder-name" })
assert_equal "cloud", builder.name
assert_equal \
"docker buildx build --push --platform linux/#{local_arch} --builder cloud-docker-org-name-builder-name -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .",
"docker buildx build --output=type=registry --platform linux/#{local_arch} --builder cloud-docker-org-name-builder-name -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .",
builder.push.join(" ")
end
test "build args" do
builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } })
assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile",
"--label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile",
builder.target.build_options.join(" ")
end
@@ -107,7 +107,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
FileUtils.touch("Dockerfile")
builder = new_builder_command(builder: { "secrets" => [ "token_a", "token_b" ] })
assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"token_a\" --secret id=\"token_b\" --file Dockerfile",
"--label service=\"app\" --secret id=\"token_a\" --secret id=\"token_b\" --file Dockerfile",
builder.target.build_options.join(" ")
end
end
@@ -116,7 +116,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
Pathname.any_instance.expects(:exist?).returns(true).once
builder = new_builder_command(builder: { "dockerfile" => "Dockerfile.xyz" })
assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile.xyz",
"--label service=\"app\" --file Dockerfile.xyz",
builder.target.build_options.join(" ")
end
@@ -131,21 +131,21 @@ class CommandsBuilderTest < ActiveSupport::TestCase
test "build target" do
builder = new_builder_command(builder: { "target" => "prod" })
assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --target prod",
"--label service=\"app\" --file Dockerfile --target prod",
builder.target.build_options.join(" ")
end
test "build context" do
builder = new_builder_command(builder: { "context" => ".." })
assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile ..",
"docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile ..",
builder.push.join(" ")
end
test "push with build args" do
builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } })
assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile .",
"docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile .",
builder.push.join(" ")
end
@@ -154,7 +154,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
FileUtils.touch("Dockerfile")
builder = new_builder_command(builder: { "secrets" => [ "a", "b" ] })
assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" --file Dockerfile .",
"docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" --file Dockerfile .",
builder.push.join(" ")
end
end
@@ -163,7 +163,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "ssh" => "default=$SSH_AUTH_SOCK" })
assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --ssh default=$SSH_AUTH_SOCK",
"--label service=\"app\" --file Dockerfile --ssh default=$SSH_AUTH_SOCK",
builder.target.build_options.join(" ")
end
@@ -174,35 +174,35 @@ class CommandsBuilderTest < ActiveSupport::TestCase
test "context build" do
builder = new_builder_command(builder: { "context" => "./foo" })
assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile ./foo",
"docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile ./foo",
builder.push.join(" ")
end
test "push with provenance" do
builder = new_builder_command(builder: { "provenance" => "mode=max" })
assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --provenance mode=max .",
"docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --provenance mode=max .",
builder.push.join(" ")
end
test "push with provenance false" do
builder = new_builder_command(builder: { "provenance" => false })
assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --provenance false .",
"docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --provenance false .",
builder.push.join(" ")
end
test "push with sbom" do
builder = new_builder_command(builder: { "sbom" => true })
assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --sbom true .",
"docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --sbom true .",
builder.push.join(" ")
end
test "push with sbom false" do
builder = new_builder_command(builder: { "sbom" => false })
assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --sbom false .",
"docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --sbom false .",
builder.push.join(" ")
end

View File

@@ -0,0 +1,54 @@
require "test_helper"
class ConfigurationBootTest < ActiveSupport::TestCase
test "no group strategy" do
deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" },
servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => [ "1.1.1.3", "1.1.1.4" ] }
}
config = Kamal::Configuration.new(deploy)
assert_nil config.boot.limit
assert_nil config.boot.wait
end
test "specific limit group strategy" do
deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" },
servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => [ "1.1.1.3", "1.1.1.4" ] },
boot: { "limit" => 3, "wait" => 2 }
}
config = Kamal::Configuration.new(deploy)
assert_equal 3, config.boot.limit
assert_equal 2, config.boot.wait
end
test "percentage-based group strategy" do
deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" },
servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => [ "1.1.1.3", "1.1.1.4" ] },
boot: { "limit" => "50%", "wait" => 2 }
}
config = Kamal::Configuration.new(deploy)
assert_equal 2, config.boot.limit
assert_equal 2, config.boot.wait
end
test "percentage-based group strategy limit is at least 1" do
deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" },
servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => [ "1.1.1.3", "1.1.1.4" ] },
boot: { "limit" => "1%", "wait" => 2 }
}
config = Kamal::Configuration.new(deploy)
assert_equal 1, config.boot.limit
assert_equal 2, config.boot.wait
end
end

View File

@@ -1,19 +0,0 @@
service: app
image: dhh/app
servers:
web:
- "1.1.1.1"
- "1.1.1.2"
workers:
- "1.1.1.3"
- "1.1.1.4"
builder:
arch: amd64
registry:
username: user
password: pw
boot:
limit: 1%
wait: 2

View File

@@ -1,19 +0,0 @@
service: app
image: dhh/app
servers:
web:
- "1.1.1.1"
- "1.1.1.2"
workers:
- "1.1.1.3"
- "1.1.1.4"
builder:
arch: amd64
registry:
username: user
password: pw
boot:
limit: 1%
wait: 2

View File

@@ -15,7 +15,9 @@ class AppTest < IntegrationTest
# kamal app start does not wait
wait_for_app_to_be_up
kamal :app, :boot
output = kamal :app, :boot, "--verbose", capture: true
assert_match "Booting app on vm1,vm2...", output
assert_match "Booted app on vm1,vm2...", output
wait_for_app_to_be_up

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Booted app on ${KAMAL_HOSTS}..."
mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-app-boot

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Booting app on ${KAMAL_HOSTS}..."
mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-app-boot

View File

@@ -8,19 +8,19 @@ class MainTest < IntegrationTest
kamal :deploy
assert_app_is_up version: first_version
assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy"
assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "pre-app-boot", "post-app-boot", "post-deploy"
assert_envs version: first_version
second_version = update_app_rev
kamal :redeploy
assert_app_is_up version: second_version
assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy"
assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "pre-app-boot", "post-app-boot", "post-deploy"
assert_accumulated_assets first_version, second_version
kamal :rollback, first_version
assert_hooks_ran "pre-connect", "pre-deploy", "post-deploy"
assert_hooks_ran "pre-connect", "pre-deploy", "pre-app-boot", "post-app-boot", "post-deploy"
assert_app_is_up version: first_version
details = kamal :details, capture: true

View File

@@ -156,14 +156,45 @@ class AwsSecretsManagerAdapterTest < SecretAdapterTestCase
assert_equal "AWS CLI is not installed", error.message
end
test "fetch without account option omits --profile" do
stub_ticks.with("aws --version 2> /dev/null")
stub_ticks
.with("aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2")
.returns(<<~JSON)
{
"SecretValues": [
{
"ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret",
"Name": "secret",
"VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv",
"SecretString": "{\\"KEY1\\":\\"VALUE1\\", \\"KEY2\\":\\"VALUE2\\"}",
"VersionStages": [
"AWSCURRENT"
],
"CreatedDate": "2024-01-01T00:00:00.000000"
}
],
"Errors": []
}
JSON
json = JSON.parse(shellunescape(run_command("fetch", "--from", "secret", "KEY1", "KEY2", account: nil)))
expected_json = {
"secret/KEY1"=>"VALUE1",
"secret/KEY2"=>"VALUE2"
}
assert_equal expected_json, json
end
private
def run_command(*command)
def run_command(*command, account: "default")
stdouted do
Kamal::Cli::Secrets.start \
[ *command,
"-c", "test/fixtures/deploy_with_accessories.yml",
"--adapter", "aws_secrets_manager",
"--account", "default" ]
args = [ *command,
"-c", "test/fixtures/deploy_with_accessories.yml",
"--adapter", "aws_secrets_manager" ]
args += [ "--account", account ] if account
Kamal::Cli::Secrets.start(args)
end
end
end

View File

@@ -36,6 +36,10 @@ class ActiveSupport::TestCase
extend Rails::LineFiltering
private
setup do
SSHKit::Backend::Netssh.pool.close_connections
end
def stdouted
capture(:stdout) { yield }.strip
end