Compare commits

..

5 Commits

Author SHA1 Message Date
Donal McBreen
f48f528043 Bump version for 1.5.1 2024-04-26 14:26:02 +01:00
Donal McBreen
ec0a082542 Merge pull request #779 from basecamp/fix-log-following
Escape single quotes to fix log following
2024-04-26 14:25:27 +01:00
Donal McBreen
6c638a8a77 Merge pull request #778 from basecamp/glob-match-roles-and-hosts
Allow glob matches for roles and hosts
2024-04-26 14:20:17 +01:00
Donal McBreen
1f5b936fa2 Escape single quotes to fix log following
Fixes: https://github.com/basecamp/kamal/issues/777
2024-04-26 14:16:19 +01:00
Donal McBreen
f785451cc7 Allow glob matches for roles and hosts
This lets you do things like:

```
kamal details -h '1.1.1.[1-9]'
kamal details -r 'w{eb,orkers}'
```
2024-04-26 13:43:52 +01:00
16 changed files with 45 additions and 149 deletions

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ class Kamal::Commands::App < Kamal::Commands::Base
ACTIVE_DOCKER_STATUSES = [ :running, :restarting ]
attr_reader :role
attr_reader :role, :role
def initialize(config, role: nil)
super(config)

View File

@@ -18,7 +18,7 @@ module Kamal::Commands
elsif config.ssh.proxy && config.ssh.proxy.is_a?(Net::SSH::Proxy::Command)
cmd << " -o ProxyCommand='#{config.ssh.proxy.command_line_template}'"
end
cmd << " -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ")}'"
cmd << " -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ").gsub("'", "'\\\\''")}'"
end
end

View File

@@ -71,9 +71,7 @@ class Kamal::Configuration
def roles
@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
@roles ||= role_names.collect { |role_name| Role.new(role_name, config: self) }
end
def role(name)
@@ -327,14 +325,6 @@ class Kamal::Configuration
raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
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
@git_version ||=
if Kamal::Git.used?

View File

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

View File

@@ -66,13 +66,12 @@ module Kamal::Utils
Array(filters).select do |filter|
matches += Array(items).select do |item|
# Only allow * for a wildcard
pattern = Regexp.escape(filter).gsub('\*', ".*")
# items are roles or hosts
(item.respond_to?(:name) ? item.name : item).match(/^#{pattern}$/)
File.fnmatch(filter, item.to_s, File::FNM_EXTGLOB)
end
end
matches
matches.uniq
end
def stable_sort!(elements, &block)

View File

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

View File

@@ -218,9 +218,9 @@ class CliAppTest < CliTestCase
test "logs with follow" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.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'")
.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 "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")
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
test "version" do

View File

@@ -24,6 +24,9 @@ class CommanderTest < ActiveSupport::TestCase
@kamal.specific_hosts = [ "*" ]
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.hosts
@kamal.specific_hosts = [ "1.1.1.[12]" ]
assert_equal [ "1.1.1.1", "1.1.1.2" ], @kamal.hosts
exception = assert_raises(ArgumentError) do
@kamal.specific_hosts = [ "*miss" ]
end
@@ -57,6 +60,9 @@ class CommanderTest < ActiveSupport::TestCase
@kamal.specific_roles = [ "*" ]
assert_equal [ "web", "workers" ], @kamal.roles.map(&:name)
@kamal.specific_roles = [ "w{eb,orkers}" ]
assert_equal [ "web", "workers" ], @kamal.roles.map(&:name)
exception = assert_raises(ArgumentError) do
@kamal.specific_roles = [ "*miss" ]
end

View File

@@ -154,19 +154,19 @@ class CommandsAppTest < ActiveSupport::TestCase
test "follow logs" do
assert_equal \
"ssh -t root@app-1 -p 22 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --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")
assert_equal \
"ssh -t root@app-1 -p 22 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --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")
assert_equal \
"ssh -t root@app-1 -p 22 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1'",
"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)
assert_equal \
"ssh -t root@app-1 -p 22 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1 | grep \"Completed\"'",
"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")
end

View File

@@ -53,8 +53,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
test "custom labels via role specialization" do
@deploy_with_roles[:labels] = { "my.custom.label" => "50" }
@deploy_with_roles[:servers]["workers"]["labels"] = { "my.custom.label" => "70" }
config_with_roles = Kamal::Configuration.new(@deploy_with_roles)
assert_equal "70", config_with_roles.role(:workers).labels["my.custom.label"]
assert_equal "70", @config_with_roles.role(:workers).labels["my.custom.label"]
end
test "overwriting default traefik label" do
@@ -110,8 +109,6 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
]
}
config_with_roles = Kamal::Configuration.new(@deploy_with_roles)
ENV["REDIS_PASSWORD"] = "secret456"
ENV["DB_PASSWORD"] = "secret&\"123"
@@ -120,8 +117,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
DB_PASSWORD=secret&\"123
ENV
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 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
ensure
ENV["REDIS_PASSWORD"] = nil
ENV["DB_PASSWORD"] = nil
@@ -138,16 +135,14 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
]
}
config_with_roles = Kamal::Configuration.new(@deploy_with_roles)
ENV["DB_PASSWORD"] = "secret123"
expected_secrets_file = <<~ENV
DB_PASSWORD=secret123
ENV
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 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
ensure
ENV["DB_PASSWORD"] = nil
end
@@ -190,16 +185,14 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
}
}
config_with_roles = Kamal::Configuration.new(@deploy_with_roles)
ENV["REDIS_PASSWORD"] = "secret456"
expected_secrets_file = <<~ENV
REDIS_PASSWORD=secret456
ENV
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 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
ensure
ENV["REDIS_PASSWORD"] = nil
end

View File

@@ -1,42 +0,0 @@
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

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

View File

@@ -131,16 +131,4 @@ class IntegrationTest < ActiveSupport::TestCase
puts "Tried to get the response code again and got #{app_response.code}"
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

View File

@@ -92,33 +92,6 @@ class MainTest < IntegrationTest
assert_no_images_or_containers
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
def assert_local_env_file(contents)
assert_equal contents, deployer_exec("cat .env", capture: true)
@@ -166,7 +139,7 @@ class MainTest < IntegrationTest
assert vm1_container_ids.any?
end
def rename_roles
deployer_exec "./rename_roles.sh #{@app}", workdir: "/"
def assert_container_running(host:, name:)
assert docker_compose("exec #{host} docker ps --filter=name=#{name} -q", capture: true).strip.present?
end
end