Compare commits

...

15 Commits

Author SHA1 Message Date
Donal McBreen
fa73d722ea Bump version for 1.8.0 2024-07-15 14:21:23 +01:00
Donal McBreen
c535e4e44f Merge pull request #883 from basecamp/revert-840-main
Revert "Add x25519 gem, support Curve25519"
2024-07-15 13:56:49 +01:00
Donal McBreen
0ea07b1760 Merge pull request #878 from pagbrl/main
feat: Use git email as performer when available
2024-07-15 13:41:17 +01:00
Donal McBreen
03b531f179 Merge pull request #865 from basecamp/clean-envify-env
Ensure envify templates aren't polluted by existing env
2024-07-15 13:41:03 +01:00
Donal McBreen
d8570d1c2c Merge pull request #847 from basecamp/remove-ruby-2.7-from-ci
Remove Ruby 2.7 from CI
2024-07-15 13:40:37 +01:00
Donal McBreen
3fe70b458d Merge pull request #862 from jeromedalbert/bump-sshkit
Bump sshkit to support unbracketed IPv6 addresses
2024-07-15 13:40:18 +01:00
Donal McBreen
ade8b43599 Merge pull request #866 from acidtib/ssh-key-overwrite
Configurable SSH Identity
2024-07-15 13:39:51 +01:00
Donal McBreen
d24fc3ca4e Revert "Add x25519 gem, support Curve25519" 2024-07-15 13:36:50 +01:00
Donal McBreen
7c244bbb98 Merge pull request #879 from basecamp/seed-mirror
Seed docker mirrors by pulling once per mirror first
2024-07-15 13:30:53 +01:00
Donal McBreen
1369c46a83 Seed docker mirrors by pulling once per mirror first
Find the first registry mirror on each host. If we find any, pull the
images on one host per mirror, then do the remainder concurrently.

The initial pulls will seed the mirrors ensuring that we pull the image
from Docker Hub once each.

This works best if there is only one mirror on each host.
2024-07-11 16:20:37 +01:00
Paul Gabriel
deccf1cfaf feat: Use git email as performer when available 2024-07-11 11:19:44 +02:00
acidtib
44726ff65a overwrite ssh identity 2024-06-26 17:14:13 -06:00
Jerome Dalbert
fd0d4af21f Bump sshkit to support unbracketed IPv6 addresses
Set sshkit minimum version to 1.23.0, which includes an enhancement to
support unbracketed IPv6 addresses.

See https://github.com/capistrano/sshkit/pull/538
2024-06-25 12:17:40 -07:00
Jeremy Daer
13409ada5a Ensure envify templates aren't polluted by existing env
Setting `GITHUB_TOKEN` as in the docs results in reusing the existing
`GITHUB_TOKEN` since `gh` returns that env var if it's set:
```bash
GITHUB_TOKEN=junk gh config get -h github.com oauth_token
junk
```

Using the original env ensures that the templates will be evaluated the
same way regardless of whether envify had been previously invoked.
2024-06-25 11:14:34 -07:00
Donal McBreen
e160852e4d Remove Ruby 2.7 from CI
It's EOL since March 2023.
2024-06-20 08:54:55 +01:00
20 changed files with 175 additions and 51 deletions

View File

@@ -24,25 +24,12 @@ jobs:
strategy: strategy:
matrix: matrix:
ruby-version: ruby-version:
- "2.7"
- "3.1" - "3.1"
- "3.2" - "3.2"
- "3.3" - "3.3"
gemfile: gemfile:
- Gemfile - Gemfile
- gemfiles/ruby_2.7.gemfile
- gemfiles/rails_edge.gemfile - gemfiles/rails_edge.gemfile
exclude:
- ruby-version: "2.7"
gemfile: Gemfile
- ruby-version: "2.7"
gemfile: gemfiles/rails_edge.gemfile
- ruby-version: "3.1"
gemfile: gemfiles/ruby_2.7.gemfile
- ruby-version: "3.2"
gemfile: gemfiles/ruby_2.7.gemfile
- ruby-version: "3.3"
gemfile: gemfiles/ruby_2.7.gemfile
name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }} name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true continue-on-error: true

View File

@@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
kamal (1.7.3) kamal (1.8.0)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)
@@ -9,9 +9,8 @@ PATH
dotenv (~> 2.8) dotenv (~> 2.8)
ed25519 (~> 1.2) ed25519 (~> 1.2)
net-ssh (~> 7.0) net-ssh (~> 7.0)
sshkit (>= 1.22.2, < 2.0) sshkit (>= 1.23.0, < 2.0)
thor (~> 1.2) thor (~> 1.2)
x25519 (~> 1.0, >= 1.0.10)
zeitwerk (~> 2.5) zeitwerk (~> 2.5)
GEM GEM
@@ -154,9 +153,8 @@ GEM
rubocop-rails rubocop-rails
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
sshkit (1.22.2) sshkit (1.23.0)
base64 base64
mutex_m
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-sftp (>= 2.1.2) net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
@@ -166,7 +164,6 @@ GEM
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0) unicode-display_width (2.5.0)
webrick (1.8.1) webrick (1.8.1)
x25519 (1.0.10)
zeitwerk (2.6.12) zeitwerk (2.6.12)
PLATFORMS PLATFORMS

View File

@@ -1,6 +0,0 @@
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gemspec path: "../"
gem "nokogiri", "~> 1.15.0"

View File

@@ -12,13 +12,12 @@ Gem::Specification.new do |spec|
spec.executables = %w[ kamal ] spec.executables = %w[ kamal ]
spec.add_dependency "activesupport", ">= 7.0" spec.add_dependency "activesupport", ">= 7.0"
spec.add_dependency "sshkit", ">= 1.22.2", "< 2.0" spec.add_dependency "sshkit", ">= 1.23.0", "< 2.0"
spec.add_dependency "net-ssh", "~> 7.0" spec.add_dependency "net-ssh", "~> 7.0"
spec.add_dependency "thor", "~> 1.2" spec.add_dependency "thor", "~> 1.2"
spec.add_dependency "dotenv", "~> 2.8" spec.add_dependency "dotenv", "~> 2.8"
spec.add_dependency "zeitwerk", "~> 2.5" spec.add_dependency "zeitwerk", "~> 2.5"
spec.add_dependency "ed25519", "~> 1.2" spec.add_dependency "ed25519", "~> 1.2"
spec.add_dependency "x25519", "~> 1.0", ">= 1.0.10"
spec.add_dependency "bcrypt_pbkdf", "~> 1.0" spec.add_dependency "bcrypt_pbkdf", "~> 1.0"
spec.add_dependency "concurrent-ruby", "~> 1.2" spec.add_dependency "concurrent-ruby", "~> 1.2"
spec.add_dependency "base64", "~> 0.2" spec.add_dependency "base64", "~> 0.2"

View File

@@ -25,12 +25,17 @@ module Kamal::Cli
def initialize(*) def initialize(*)
super super
@original_env = ENV.to_h.dup @original_env = ENV.to_h.dup
load_envs load_env
initialize_commander(options_with_subcommand_class_options) initialize_commander(options_with_subcommand_class_options)
end end
private private
def load_envs def reload_env
reset_env
load_env
end
def load_env
if destination = options[:destination] if destination = options[:destination]
Dotenv.load(".env.#{destination}", ".env") Dotenv.load(".env.#{destination}", ".env")
else else
@@ -38,10 +43,27 @@ module Kamal::Cli
end end
end end
def reload_envs def reset_env
replace_env @original_env
end
def replace_env(env)
ENV.clear ENV.clear
ENV.update(@original_env) ENV.update(env)
load_envs end
def with_original_env
keeping_current_env do
reset_env
yield
end
end
def keeping_current_env
current_env = ENV.to_h.dup
yield
ensure
replace_env(current_env)
end end
def options_with_subcommand_class_options def options_with_subcommand_class_options

View File

@@ -59,11 +59,14 @@ class Kamal::Cli::Build < Kamal::Cli::Base
desc "pull", "Pull app image from registry onto servers" desc "pull", "Pull app image from registry onto servers"
def pull def pull
on(KAMAL.hosts) do if (first_hosts = mirror_hosts).any?
execute *KAMAL.auditor.record("Pulled image with version #{KAMAL.config.version}"), verbosity: :debug #  Pull on a single host per mirror first to seed them
execute *KAMAL.builder.clean, raise_on_non_zero_exit: false say "Pulling image on #{first_hosts.join(", ")} to seed the #{"mirror".pluralize(first_hosts.count)}...", :magenta
execute *KAMAL.builder.pull pull_on_hosts(first_hosts)
execute *KAMAL.builder.validate_image say "Pulling image on remaining hosts...", :magenta
pull_on_hosts(KAMAL.hosts - first_hosts)
else
pull_on_hosts(KAMAL.hosts)
end end
end end
@@ -131,4 +134,28 @@ class Kamal::Cli::Build < Kamal::Cli::Base
end end
end end
end end
def mirror_hosts
if KAMAL.hosts.many?
mirror_hosts = Concurrent::Hash.new
on(KAMAL.hosts) do |host|
first_mirror = capture_with_info(*KAMAL.builder.first_mirror).strip.presence
mirror_hosts[first_mirror] ||= host if first_mirror
rescue SSHKit::Command::Failed => e
raise unless e.message =~ /error calling index: reflect: slice index out of range/
end
mirror_hosts.values
else
[]
end
end
def pull_on_hosts(hosts)
on(hosts) do
execute *KAMAL.auditor.record("Pulled image with version #{KAMAL.config.version}"), verbosity: :debug
execute *KAMAL.builder.clean, raise_on_non_zero_exit: false
execute *KAMAL.builder.pull
execute *KAMAL.builder.validate_image
end
end
end end

View File

@@ -191,10 +191,12 @@ class Kamal::Cli::Main < Kamal::Cli::Base
end end
if Pathname.new(File.expand_path(env_template_path)).exist? if Pathname.new(File.expand_path(env_template_path)).exist?
File.write(env_path, ERB.new(File.read(env_template_path), trim_mode: "-").result, perm: 0600) # Ensure existing env doesn't pollute template evaluation
content = with_original_env { ERB.new(File.read(env_template_path), trim_mode: "-").result }
File.write(env_path, content, perm: 0600)
unless options[:skip_push] unless options[:skip_push]
reload_envs reload_env
invoke "kamal:cli:env:push", options invoke "kamal:cli:env:push", options
end end
else else

View File

@@ -2,7 +2,7 @@ require "active_support/core_ext/string/filters"
class Kamal::Commands::Builder < Kamal::Commands::Base class Kamal::Commands::Builder < Kamal::Commands::Base
delegate :create, :remove, :push, :clean, :pull, :info, :context_hosts, :config_context_hosts, :validate_image, delegate :create, :remove, :push, :clean, :pull, :info, :context_hosts, :config_context_hosts, :validate_image,
to: :target :first_mirror, to: :target
include Clone include Clone

View File

@@ -40,6 +40,10 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
[] []
end end
def first_mirror
docker(:info, "--format '{{index .RegistryConfig.Mirrors 0}}'")
end
private private
def build_tags def build_tags
[ "-t", config.absolute_image, "-t", config.latest_image ] [ "-t", config.absolute_image, "-t", config.latest_image ]

View File

@@ -44,3 +44,23 @@ ssh:
# Defaults to `fatal`. Set this to debug if you are having # Defaults to `fatal`. Set this to debug if you are having
# SSH connection issues. # SSH connection issues.
log_level: debug log_level: debug
# Keys Only
#
# Set to true to use only private keys from keys and key_data parameters,
# even if ssh-agent offers more identities. This option is intended for
# situations where ssh-agent offers many different identites or you have
# a need to overwrite all identites and force a single one.
keys_only: false
# Keys
#
# An array of file names of private keys to use for publickey
# and hostbased authentication
keys: [ "~/.ssh/id.pem" ]
# Key Data
#
# An array of strings, with each element of the array being
# a raw private key in PEM format.
key_data: [ "-----BEGIN OPENSSH PRIVATE KEY-----" ]

View File

@@ -26,8 +26,20 @@ class Kamal::Configuration::Ssh
end end
end end
def keys_only
ssh_config["keys_only"]
end
def keys
ssh_config["keys"]
end
def key_data
ssh_config["key_data"]
end
def options def options
{ user: user, port: port, proxy: proxy, logger: logger, keepalive: true, keepalive_interval: 30 }.compact { user: user, port: port, proxy: proxy, logger: logger, keepalive: true, keepalive_interval: 30, keys_only: keys_only, keys: keys, key_data: key_data }.compact
end end
def to_h def to_h

View File

@@ -9,6 +9,10 @@ module Kamal::Git
`git config user.name`.strip `git config user.name`.strip
end end
def email
`git config user.email`.strip
end
def revision def revision
`git rev-parse HEAD`.strip `git rev-parse HEAD`.strip
end end

View File

@@ -10,7 +10,7 @@ class Kamal::Tags
def default_tags(config) def default_tags(config)
{ recorded_at: Time.now.utc.iso8601, { recorded_at: Time.now.utc.iso8601,
performer: `whoami`.chomp, performer: Kamal::Git.email.presence || `whoami`.chomp,
destination: config.destination, destination: config.destination,
version: config.version, version: config.version,
service_version: service_version(config), service_version: service_version(config),

View File

@@ -1,3 +1,3 @@
module Kamal module Kamal
VERSION = "1.7.3" VERSION = "1.8.0"
end end

View File

@@ -169,12 +169,41 @@ class CliBuildTest < CliTestCase
test "pull" do test "pull" do
run_command("pull").tap do |output| run_command("pull").tap do |output|
assert_match /docker info --format '{{index .RegistryConfig.Mirrors 0}}'/, output
assert_match /docker image rm --force dhh\/app:999/, output assert_match /docker image rm --force dhh\/app:999/, output
assert_match /docker pull dhh\/app:999/, output assert_match /docker pull dhh\/app:999/, output
assert_match "docker inspect -f '{{ .Config.Labels.service }}' dhh/app:999 | grep -x app || (echo \"Image dhh/app:999 is missing the 'service' label\" && exit 1)", output assert_match "docker inspect -f '{{ .Config.Labels.service }}' dhh/app:999 | grep -x app || (echo \"Image dhh/app:999 is missing the 'service' label\" && exit 1)", output
end end
end end
test "pull with mirror" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :info, "--format '{{index .RegistryConfig.Mirrors 0}}'")
.returns("registry-mirror.example.com")
.at_least_once
run_command("pull").tap do |output|
assert_match /Pulling image on 1\.1\.1\.\d to seed the mirror\.\.\./, output
assert_match "Pulling image on remaining hosts...", output
assert_match /docker pull dhh\/app:999/, output
assert_match "docker inspect -f '{{ .Config.Labels.service }}' dhh/app:999 | grep -x app || (echo \"Image dhh/app:999 is missing the 'service' label\" && exit 1)", output
end
end
test "pull with mirrors" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :info, "--format '{{index .RegistryConfig.Mirrors 0}}'")
.returns("registry-mirror.example.com", "registry-mirror2.example.com")
.at_least_once
run_command("pull").tap do |output|
assert_match /Pulling image on 1\.1\.1\.\d, 1\.1\.1\.\d to seed the mirrors\.\.\./, output
assert_match "Pulling image on remaining hosts...", output
assert_match /docker pull dhh\/app:999/, output
assert_match "docker inspect -f '{{ .Config.Labels.service }}' dhh/app:999 | grep -x app || (echo \"Image dhh/app:999 is missing the 'service' label\" && exit 1)", output
end
end
test "create" do test "create" do
run_command("create").tap do |output| run_command("create").tap do |output|
assert_match /docker buildx create --use --name kamal-app-multiarch/, output assert_match /docker buildx create --use --name kamal-app-multiarch/, output

View File

@@ -42,7 +42,7 @@ class CliTestCase < ActiveSupport::TestCase
end end
def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, subcommand: nil, runtime: false) def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, subcommand: nil, runtime: false)
performer = `whoami`.strip performer = Kamal::Git.email.presence || `whoami`.chomp
service = service_version.split("@").first service = service_version.split("@").first
assert_match "Running the #{hook} hook...\n", output assert_match "Running the #{hook} hook...\n", output

View File

@@ -1,6 +1,9 @@
require_relative "cli_test_case" require_relative "cli_test_case"
class CliMainTest < CliTestCase class CliMainTest < CliTestCase
setup { @original_env = ENV.to_h.dup }
teardown { ENV.clear; ENV.update @original_env }
test "setup" do test "setup" 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 }
@@ -122,6 +125,11 @@ class CliMainTest < CliTestCase
.with(:docker, :buildx, :inspect, "kamal-app-multiarch", "> /dev/null") .with(:docker, :buildx, :inspect, "kamal-app-multiarch", "> /dev/null")
.returns("") .returns("")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :info, "--format '{{index .RegistryConfig.Mirrors 0}}'")
.returns("")
.at_least_once
assert_raises(Kamal::Cli::LockError) do assert_raises(Kamal::Cli::LockError) do
run_command("deploy") run_command("deploy")
end end
@@ -155,6 +163,11 @@ class CliMainTest < CliTestCase
.with(:docker, :buildx, :inspect, "kamal-app-multiarch", "> /dev/null") .with(:docker, :buildx, :inspect, "kamal-app-multiarch", "> /dev/null")
.returns("") .returns("")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :info, "--format '{{index .RegistryConfig.Mirrors 0}}'")
.returns("")
.at_least_once
assert_raises(SSHKit::Runner::ExecuteError) do assert_raises(SSHKit::Runner::ExecuteError) do
run_command("deploy") run_command("deploy")
end end
@@ -434,7 +447,7 @@ class CliMainTest < CliTestCase
end end
test "envify" do test "envify" do
with_test_dot_env_erb(contents: "HELLO=<%= 'world' %>") do with_test_dotenv(".env.erb": "HELLO=<%= 'world' %>") do
run_command("envify") run_command("envify")
assert_equal("HELLO=world", File.read(".env")) assert_equal("HELLO=world", File.read(".env"))
end end
@@ -448,14 +461,14 @@ class CliMainTest < CliTestCase
<% end -%> <% end -%>
EOF EOF
with_test_dot_env_erb(contents: file) do with_test_dotenv(".env.erb": file) do
run_command("envify") run_command("envify")
assert_equal("HELLO=world\nKEY=value\n", File.read(".env")) assert_equal("HELLO=world\nKEY=value\n", File.read(".env"))
end end
end end
test "envify with destination" do test "envify with destination" do
with_test_dot_env_erb(contents: "HELLO=<%= 'world' %>", file: ".env.world.erb") do with_test_dotenv(".env.world.erb": "HELLO=<%= 'world' %>") do
run_command("envify", "-d", "world", config_file: "deploy_for_dest") run_command("envify", "-d", "world", config_file: "deploy_for_dest")
assert_equal "HELLO=world", File.read(".env.world") assert_equal "HELLO=world", File.read(".env.world")
end end
@@ -470,6 +483,13 @@ class CliMainTest < CliTestCase
run_command("envify", "--skip-push") run_command("envify", "--skip-push")
end end
test "envify with clean env" do
with_test_dotenv(".env": "HELLO=already", ".env.erb": "HELLO=<%= ENV.fetch 'HELLO', 'never' %>") do
run_command("envify", "--skip-push")
assert_equal "HELLO=never", File.read(".env")
end
end
test "remove with confirmation" do test "remove with confirmation" do
run_command("remove", "-y", config_file: "deploy_with_accessories").tap do |output| run_command("remove", "-y", config_file: "deploy_with_accessories").tap do |output|
assert_match /docker container stop traefik/, output assert_match /docker container stop traefik/, output
@@ -522,14 +542,16 @@ class CliMainTest < CliTestCase
stdouted { Kamal::Cli::Main.start([ *command, "-c", "test/fixtures/#{config_file}.yml" ]) } stdouted { Kamal::Cli::Main.start([ *command, "-c", "test/fixtures/#{config_file}.yml" ]) }
end end
def with_test_dot_env_erb(contents:, file: ".env.erb") def with_test_dotenv(**files)
Dir.mktmpdir do |dir| Dir.mktmpdir do |dir|
fixtures_dup = File.join(dir, "test") fixtures_dup = File.join(dir, "test")
FileUtils.mkdir_p(fixtures_dup) FileUtils.mkdir_p(fixtures_dup)
FileUtils.cp_r("test/fixtures/", fixtures_dup) FileUtils.cp_r("test/fixtures/", fixtures_dup)
Dir.chdir(dir) do Dir.chdir(dir) do
File.write(file, contents) files.each do |filename, contents|
File.binwrite(filename.to_s, contents)
end
yield yield
end end
end end

View File

@@ -12,7 +12,7 @@ class CommandsAuditorTest < ActiveSupport::TestCase
} }
@auditor = new_command @auditor = new_command
@performer = `whoami`.strip @performer = Kamal::Git.email.presence || `whoami`.chomp
@recorded_at = Time.now.utc.iso8601 @recorded_at = Time.now.utc.iso8601
end end

View File

@@ -200,6 +200,11 @@ class CommandsBuilderTest < ActiveSupport::TestCase
assert_equal [ "unix:///var/run/docker.sock", "ssh://host" ], command.config_context_hosts assert_equal [ "unix:///var/run/docker.sock", "ssh://host" ], command.config_context_hosts
end end
test "mirror count" do
command = new_builder_command
assert_equal "docker info --format '{{index .RegistryConfig.Mirrors 0}}'", command.first_mirror.join(" ")
end
private private
def new_builder_command(additional_config = {}) def new_builder_command(additional_config = {})
Kamal::Commands::Builder.new(Kamal::Configuration.new(@config.merge(additional_config), version: "123")) Kamal::Commands::Builder.new(Kamal::Configuration.new(@config.merge(additional_config), version: "123"))

View File

@@ -11,7 +11,7 @@ class CommandsHookTest < ActiveSupport::TestCase
traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } } traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
} }
@performer = `whoami`.strip @performer = Kamal::Git.email.presence || `whoami`.chomp
@recorded_at = Time.now.utc.iso8601 @recorded_at = Time.now.utc.iso8601
end end