kamal 2.8.1 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd8b2329effde7405d2a1b8d60f394d1da81faaf5e8bcd6980c83f8bb590f988
4
- data.tar.gz: 5543d21fbe88acf86a24fcf1f44dff30e0aaf410b3ead0293b0f005b15fa2cbb
3
+ metadata.gz: bd7c6e6e18ca7cc95712642dfdb5ec45c33e06b6a02750e5ee7a3f1ac3428ea2
4
+ data.tar.gz: 1a8acfe5ec44636827ca70da1263289e6082359c58601acce9870d1d46ba750b
5
5
  SHA512:
6
- metadata.gz: bdae637114b1ad0630d5b1bbc0e76be5ebb3817d370d1f3f5a4222024f13fa3546a24806ad812e9d6d14735c2be23ed6956702723e75cc27c5ec34ebe87d72f9
7
- data.tar.gz: 8db206acbeb707f3a0d3267720b9d248cb2566517d340a61072777e0fdbbf237701d42e203d2d96b21fb7c04596896d6039c4ba9d59eb1515d47de0f85148248
6
+ metadata.gz: d1fb62392ddafc5b1fe6b54055121654bd73625eaf9808848ed60d7d40fdadf2938675b0bc881fca1297ee089f78b3768eefb96f5c10e9e8346265f20d9663e2
7
+ data.tar.gz: 6fc4f87a3ea64ebdee5c3c666cdafa5b15a0f7b243f575b402d82a2c8eec5b3802a05c0b5a80f6856eb92c24647b65a7bbdb8a68fd208dc873f1cd55d725894e
@@ -128,12 +128,13 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
128
128
 
129
129
  desc "details [NAME]", "Show details about accessory on host (use NAME=all to show all accessories)"
130
130
  def details(name)
131
+ quiet = options[:quiet]
131
132
  if name == "all"
132
133
  KAMAL.accessory_names.each { |accessory_name| details(accessory_name) }
133
134
  else
134
135
  type = "Accessory #{name}"
135
136
  with_accessory(name) do |accessory, hosts|
136
- on(hosts) { puts_by_host host, capture_with_info(*accessory.info), type: type }
137
+ on(hosts) { puts_by_host host, capture_with_info(*accessory.info), type: type, quiet: quiet }
137
138
  end
138
139
  end
139
140
  end
@@ -145,6 +146,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
145
146
  pre_connect_if_required
146
147
 
147
148
  cmd = Kamal::Utils.join_commands(cmd)
149
+ quiet = options[:quiet]
150
+
148
151
  with_accessory(name) do |accessory, hosts|
149
152
  case
150
153
  when options[:interactive] && options[:reuse]
@@ -160,7 +163,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
160
163
  say "Launching command from existing container...", :magenta
161
164
  on(hosts) do |host|
162
165
  execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
163
- puts_by_host host, capture_with_info(*accessory.execute_in_existing_container(cmd))
166
+ puts_by_host host, capture_with_info(*accessory.execute_in_existing_container(cmd)), quiet: quiet
164
167
  end
165
168
 
166
169
  else
@@ -168,7 +171,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
168
171
  on(hosts) do |host|
169
172
  execute *KAMAL.registry.login
170
173
  execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
171
- puts_by_host host, capture_with_info(*accessory.execute_in_new_container(cmd))
174
+ puts_by_host host, capture_with_info(*accessory.execute_in_new_container(cmd)), quiet: quiet
172
175
  end
173
176
  end
174
177
  end
data/lib/kamal/cli/app.rb CHANGED
@@ -92,11 +92,12 @@ class Kamal::Cli::App < Kamal::Cli::Base
92
92
  # FIXME: Drop in favor of just containers?
93
93
  desc "details", "Show details about app containers"
94
94
  def details
95
+ quiet = options[:quiet]
95
96
  on(KAMAL.app_hosts) do |host|
96
97
  roles = KAMAL.roles_on(host)
97
98
 
98
99
  roles.each do |role|
99
- puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).info)
100
+ puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).info), quiet: quiet
100
101
  end
101
102
  end
102
103
  end
@@ -120,6 +121,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
120
121
  cmd = Kamal::Utils.join_commands(cmd)
121
122
  env = options[:env]
122
123
  detach = options[:detach]
124
+ quiet = options[:quiet]
123
125
  case
124
126
  when options[:interactive] && options[:reuse]
125
127
  say "Get current version of running container...", :magenta unless options[:version]
@@ -148,7 +150,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
148
150
 
149
151
  roles.each do |role|
150
152
  execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}", role: role), verbosity: :debug
151
- puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_existing_container(cmd, env: env))
153
+ puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_existing_container(cmd, env: env)), quiet: quiet
152
154
  end
153
155
  end
154
156
  end
@@ -164,7 +166,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
164
166
 
165
167
  roles.each do |role|
166
168
  execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
167
- puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_new_container(cmd, env: env, detach: detach))
169
+ puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_new_container(cmd, env: env, detach: detach)), quiet: quiet
168
170
  end
169
171
  end
170
172
  end
@@ -173,12 +175,14 @@ class Kamal::Cli::App < Kamal::Cli::Base
173
175
 
174
176
  desc "containers", "Show app containers on servers"
175
177
  def containers
176
- on(KAMAL.app_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_containers) }
178
+ quiet = options[:quiet]
179
+ on(KAMAL.app_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_containers), quiet: quiet }
177
180
  end
178
181
 
179
182
  desc "stale_containers", "Detect app stale containers"
180
183
  option :stop, aliases: "-s", type: :boolean, default: false, desc: "Stop the stale containers found"
181
184
  def stale_containers
185
+ quiet = options[:quiet]
182
186
  stop = options[:stop]
183
187
 
184
188
  with_lock_if_stopping do
@@ -192,10 +196,10 @@ class Kamal::Cli::App < Kamal::Cli::Base
192
196
 
193
197
  versions.each do |version|
194
198
  if stop
195
- puts_by_host host, "Stopping stale container for role #{role} with version #{version}"
199
+ puts_by_host host, "Stopping stale container for role #{role} with version #{version}", quiet: quiet
196
200
  execute *app.stop(version: version), raise_on_non_zero_exit: false
197
201
  else
198
- puts_by_host host, "Detected stale container for role #{role} with version #{version} (use `kamal app stale_containers --stop` to stop)"
202
+ puts_by_host host, "Detected stale container for role #{role} with version #{version} (use `kamal app stale_containers --stop` to stop)", quiet: quiet
199
203
  end
200
204
  end
201
205
  end
@@ -205,7 +209,8 @@ class Kamal::Cli::App < Kamal::Cli::Base
205
209
 
206
210
  desc "images", "Show app images on servers"
207
211
  def images
208
- on(KAMAL.app_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_images) }
212
+ quiet = options[:quiet]
213
+ on(KAMAL.app_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_images), quiet: quiet }
209
214
  end
210
215
 
211
216
  desc "logs", "Show log lines from app on servers (use --help to show options)"
@@ -224,6 +229,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
224
229
  since = options[:since]
225
230
  container_id = options[:container_id]
226
231
  timestamps = !options[:skip_timestamps]
232
+ quiet = options[:quiet]
227
233
 
228
234
  if options[:follow]
229
235
  lines = options[:lines].presence || ((since || grep) ? nil : 10) # Default to 10 lines if since or grep isn't set
@@ -246,9 +252,9 @@ class Kamal::Cli::App < Kamal::Cli::Base
246
252
 
247
253
  roles.each do |role|
248
254
  begin
249
- puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(container_id: container_id, timestamps: timestamps, since: since, lines: lines, grep: grep, grep_options: grep_options))
255
+ puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(container_id: container_id, timestamps: timestamps, since: since, lines: lines, grep: grep, grep_options: grep_options)), quiet: quiet
250
256
  rescue SSHKit::Command::Failed
251
- puts_by_host host, "Nothing found"
257
+ puts_by_host host, "Nothing found", quiet: quiet
252
258
  end
253
259
  end
254
260
  end
@@ -352,9 +358,10 @@ class Kamal::Cli::App < Kamal::Cli::Base
352
358
 
353
359
  desc "version", "Show app version currently running on servers"
354
360
  def version
361
+ quiet = options[:quiet]
355
362
  on(KAMAL.app_hosts) do |host|
356
363
  role = KAMAL.roles_on(host).first
357
- puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip
364
+ puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip, quiet: quiet
358
365
  end
359
366
  end
360
367
 
@@ -1,5 +1,3 @@
1
- require "uri"
2
-
3
1
  class Kamal::Cli::Build::Clone
4
2
  attr_reader :sshkit
5
3
  delegate :info, :error, :execute, :capture_with_info, to: :sshkit
@@ -0,0 +1,66 @@
1
+ require "concurrent/atomic/count_down_latch"
2
+
3
+ class Kamal::Cli::Build::PortForwarding
4
+ attr_reader :hosts, :port, :ssh_options
5
+
6
+ def initialize(hosts, port, **ssh_options)
7
+ @hosts = hosts
8
+ @port = port
9
+ @ssh_options = ssh_options
10
+ end
11
+
12
+ def forward
13
+ @done = false
14
+ forward_ports
15
+
16
+ yield
17
+ ensure
18
+ stop
19
+ end
20
+
21
+ private
22
+ def stop
23
+ @done = true
24
+ @threads.to_a.each(&:join)
25
+ end
26
+
27
+ def forward_ports
28
+ ready = Concurrent::CountDownLatch.new(hosts.size)
29
+
30
+ @threads = hosts.map do |host|
31
+ Thread.new do
32
+ begin
33
+ Net::SSH.start(host, ssh_options[:user], **ssh_options.except(:user)) do |ssh|
34
+ ssh.forward.remote(port, "localhost", port, "127.0.0.1") do |remote_port, bind_address|
35
+ if remote_port == :error
36
+ raise "Failed to establish port forward on #{host}"
37
+ else
38
+ ready.count_down
39
+ end
40
+ end
41
+
42
+ ssh.loop(0.1) do
43
+ if @done
44
+ ssh.forward.cancel_remote(port, "127.0.0.1")
45
+ break
46
+ else
47
+ true
48
+ end
49
+ end
50
+ end
51
+ rescue Exception => e
52
+ error "Error setting up port forwarding to #{host}: #{e.class}: #{e.message}"
53
+ error e.backtrace.join("\n")
54
+
55
+ raise
56
+ end
57
+ end
58
+ end
59
+
60
+ raise "Timed out waiting for port forwarding to be established" unless ready.wait(30)
61
+ end
62
+
63
+ def error(message)
64
+ SSHKit.config.output.error(message)
65
+ end
66
+ end
@@ -1,5 +1,3 @@
1
- require "uri"
2
-
3
1
  class Kamal::Cli::Build < Kamal::Cli::Base
4
2
  class BuildError < StandardError; end
5
3
 
@@ -38,29 +36,31 @@ class Kamal::Cli::Build < Kamal::Cli::Base
38
36
  say "Building with uncommitted changes:\n #{uncommitted_changes}", :yellow
39
37
  end
40
38
 
41
- with_env(KAMAL.config.builder.secrets) do
42
- run_locally do
43
- begin
44
- execute *KAMAL.builder.inspect_builder
45
- rescue SSHKit::Command::Failed => e
46
- if e.message =~ /(context not found|no builder|no compatible builder|does not exist)/
47
- warn "Missing compatible builder, so creating a new one first"
48
- begin
49
- cli.remove
50
- rescue SSHKit::Command::Failed
51
- raise unless e.message =~ /(context not found|no builder|does not exist)/
39
+ forward_local_registry_port_for_remote_builder do
40
+ with_env(KAMAL.config.builder.secrets) do
41
+ run_locally do
42
+ begin
43
+ execute *KAMAL.builder.inspect_builder
44
+ rescue SSHKit::Command::Failed => e
45
+ if e.message =~ /(context not found|no builder|no compatible builder|does not exist)/
46
+ warn "Missing compatible builder, so creating a new one first"
47
+ begin
48
+ cli.remove
49
+ rescue SSHKit::Command::Failed
50
+ raise unless e.message =~ /(context not found|no builder|does not exist)/
51
+ end
52
+ cli.create
53
+ else
54
+ raise
52
55
  end
53
- cli.create
54
- else
55
- raise
56
56
  end
57
- end
58
57
 
59
- # Get the command here to ensure the Dir.chdir doesn't interfere with it
60
- push = KAMAL.builder.push(cli.options[:output], no_cache: cli.options[:no_cache])
58
+ # Get the command here to ensure the Dir.chdir doesn't interfere with it
59
+ push = KAMAL.builder.push(cli.options[:output], no_cache: cli.options[:no_cache])
61
60
 
62
- KAMAL.with_verbosity(:debug) do
63
- Dir.chdir(KAMAL.config.builder.build_directory) { execute *push, env: KAMAL.builder.push_env }
61
+ KAMAL.with_verbosity(:debug) do
62
+ Dir.chdir(KAMAL.config.builder.build_directory) { execute *push, env: KAMAL.builder.push_env }
63
+ end
64
64
  end
65
65
  end
66
66
  end
@@ -70,7 +70,7 @@ class Kamal::Cli::Build < Kamal::Cli::Base
70
70
  def pull
71
71
  login_to_registry_remotely unless KAMAL.registry.local?
72
72
 
73
- forward_local_registry_port do
73
+ forward_local_registry_port(KAMAL.hosts, **KAMAL.config.ssh.options) do
74
74
  if (first_hosts = mirror_hosts).any?
75
75
  #  Pull on a single host per mirror first to seed them
76
76
  say "Pulling image on #{first_hosts.join(", ")} to seed the #{"mirror".pluralize(first_hosts.count)}...", :magenta
@@ -210,13 +210,30 @@ class Kamal::Cli::Build < Kamal::Cli::Base
210
210
  end
211
211
  end
212
212
 
213
- def forward_local_registry_port(&block)
213
+ def forward_local_registry_port_for_remote_builder(&block)
214
+ if KAMAL.builder.remote?
215
+ remote_uri = URI(KAMAL.config.builder.remote)
216
+ forward_local_registry_port([ remote_uri.host ], **remote_builder_ssh_options(remote_uri), &block)
217
+ else
218
+ yield
219
+ end
220
+ end
221
+
222
+ def forward_local_registry_port(hosts, **ssh_options, &block)
214
223
  if KAMAL.config.registry.local?
215
- Kamal::Cli::PortForwarding.
216
- new(KAMAL.hosts, KAMAL.config.registry.local_port).
217
- forward(&block)
224
+ say "Setting up local registry port forwarding to #{hosts.join(', ')}..."
225
+ PortForwarding.new(hosts, KAMAL.config.registry.local_port, **ssh_options).forward(&block)
218
226
  else
219
227
  yield
220
228
  end
221
229
  end
230
+
231
+ def remote_builder_ssh_options(remote_uri)
232
+ { user: remote_uri.user,
233
+ port: remote_uri.port,
234
+ keepalive: KAMAL.config.ssh.options[:keepalive],
235
+ keepalive_interval: KAMAL.config.ssh.options[:keepalive_interval],
236
+ logger: KAMAL.config.ssh.options[:logger]
237
+ }.compact
238
+ end
222
239
  end
@@ -112,8 +112,9 @@ class Kamal::Cli::Main < Kamal::Cli::Base
112
112
 
113
113
  desc "audit", "Show audit log from servers"
114
114
  def audit
115
+ quiet = options[:quiet]
115
116
  on(KAMAL.hosts) do |host|
116
- puts_by_host host, capture_with_info(*KAMAL.auditor.reveal)
117
+ puts_by_host host, capture_with_info(*KAMAL.auditor.reveal), quiet: quiet
117
118
  end
118
119
  end
119
120
 
@@ -197,7 +197,8 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
197
197
 
198
198
  desc "details", "Show details about proxy container from servers"
199
199
  def details
200
- on(KAMAL.proxy_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.proxy.info), type: "Proxy" }
200
+ quiet = options[:quiet]
201
+ on(KAMAL.proxy_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.proxy.info), type: "Proxy", quiet: quiet }
201
202
  end
202
203
 
203
204
  desc "logs", "Show log lines from proxy on servers"
@@ -24,4 +24,26 @@ class Kamal::Cli::Registry < Kamal::Cli::Base
24
24
  on(KAMAL.hosts) { execute *KAMAL.registry.logout } unless options[:skip_remote]
25
25
  end
26
26
  end
27
+
28
+ desc "login", "Log in to remote registry locally and remotely"
29
+ option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login"
30
+ option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login"
31
+ def login
32
+ if KAMAL.registry.local?
33
+ raise "Cannot use login command with a local registry. Use `kamal registry setup` instead."
34
+ end
35
+
36
+ setup
37
+ end
38
+
39
+ desc "logout", "Log out of remote registry locally and remotely"
40
+ option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login"
41
+ option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login"
42
+ def logout
43
+ if KAMAL.registry.local?
44
+ raise "Cannot use logout command with a local registry. Use `kamal registry remove` instead."
45
+ end
46
+
47
+ remove
48
+ end
27
49
  end
@@ -6,6 +6,7 @@ class Kamal::Cli::Server < Kamal::Cli::Base
6
6
 
7
7
  cmd = Kamal::Utils.join_commands(cmd)
8
8
  hosts = KAMAL.hosts
9
+ quiet = options[:quiet]
9
10
 
10
11
  case
11
12
  when options[:interactive]
@@ -19,7 +20,7 @@ class Kamal::Cli::Server < Kamal::Cli::Base
19
20
 
20
21
  on(hosts) do |host|
21
22
  execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{host}"), verbosity: :debug
22
- puts_by_host host, capture_with_info(cmd)
23
+ puts_by_host host, capture_with_info(cmd), quiet: quiet
23
24
  end
24
25
  end
25
26
  end
@@ -7,6 +7,7 @@
7
7
 
8
8
  # Option 2: Read secrets via a command
9
9
  # RAILS_MASTER_KEY=$(cat config/master.key)
10
+ # KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password)
10
11
 
11
12
  # Option 3: Read secrets via kamal secrets helpers
12
13
  # These will handle logging in and fetching the secrets in as few calls as possible
@@ -155,6 +155,7 @@ class Kamal::Commander
155
155
  SSHKit::Backend::Netssh.pool.idle_timeout = config.sshkit.pool_idle_timeout
156
156
  SSHKit::Backend::Netssh.configure do |sshkit|
157
157
  sshkit.max_concurrent_starts = config.sshkit.max_concurrent_starts
158
+ sshkit.dns_retries = config.sshkit.dns_retries
158
159
  sshkit.ssh_options = config.ssh.options
159
160
  end
160
161
  SSHKit.config.command_map[:docker] = "docker" # No need to use /usr/bin/env, just clogs up the logs
@@ -12,6 +12,7 @@ module Kamal::Commands::App::Execution
12
12
  (docker_interactive_args if interactive),
13
13
  ("--detach" if detach),
14
14
  ("--rm" unless detach),
15
+ "--name", container_name_for_exec,
15
16
  "--network", "kamal",
16
17
  *role&.env_args(host),
17
18
  *argumentize("--env", env),
@@ -22,11 +23,16 @@ module Kamal::Commands::App::Execution
22
23
  *command
23
24
  end
24
25
 
25
- def execute_in_existing_container_over_ssh(*command, env:)
26
+ def execute_in_existing_container_over_ssh(*command, env:)
26
27
  run_over_ssh execute_in_existing_container(*command, interactive: true, env: env), host: host
27
28
  end
28
29
 
29
30
  def execute_in_new_container_over_ssh(*command, env:)
30
31
  run_over_ssh execute_in_new_container(*command, interactive: true, env: env), host: host
31
32
  end
33
+
34
+ private
35
+ def container_name_for_exec
36
+ [ role.container_prefix, "exec", config.version, SecureRandom.hex(3) ].compact.join("-")
37
+ end
32
38
  end
@@ -127,6 +127,16 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
127
127
  config.builder
128
128
  end
129
129
 
130
+ def registry_config
131
+ config.registry
132
+ end
133
+
134
+ def driver_options
135
+ if registry_config.local?
136
+ [ "--driver-opt", "network=host" ]
137
+ end
138
+ end
139
+
130
140
  def platform_options(arches)
131
141
  argumentize "--platform", arches.map { |arch| "linux/#{arch}" }.join(",") if arches.any?
132
142
  end
@@ -8,14 +8,14 @@ class Kamal::Commands::Builder::Hybrid < Kamal::Commands::Builder::Remote
8
8
 
9
9
  private
10
10
  def builder_name
11
- "kamal-hybrid-#{driver}-#{remote.gsub(/[^a-z0-9_-]/, "-")}"
11
+ "kamal-hybrid-#{driver}-#{remote_builder_name_suffix}"
12
12
  end
13
13
 
14
14
  def create_local_buildx
15
- docker :buildx, :create, *platform_options(local_arches), "--name", builder_name, "--driver=#{driver}"
15
+ docker :buildx, :create, *platform_options(local_arches), "--name", builder_name, "--driver=#{driver}", *driver_options
16
16
  end
17
17
 
18
18
  def append_remote_buildx
19
- docker :buildx, :create, *platform_options(remote_arches), "--append", "--name", builder_name, remote_context_name
19
+ docker :buildx, :create, *platform_options(remote_arches), "--append", "--name", builder_name, *driver_options, remote_context_name
20
20
  end
21
21
  end
@@ -2,14 +2,7 @@ class Kamal::Commands::Builder::Local < Kamal::Commands::Builder::Base
2
2
  def create
3
3
  return if docker_driver?
4
4
 
5
- options =
6
- if KAMAL.registry.local?
7
- "--driver=#{driver} --driver-opt network=host"
8
- else
9
- "--driver=#{driver}"
10
- end
11
-
12
- docker :buildx, :create, "--name", builder_name, options
5
+ docker :buildx, :create, "--name", builder_name, "--driver=#{driver}", *driver_options
13
6
  end
14
7
 
15
8
  def remove
@@ -18,7 +11,7 @@ class Kamal::Commands::Builder::Local < Kamal::Commands::Builder::Base
18
11
 
19
12
  private
20
13
  def builder_name
21
- if KAMAL.registry.local?
14
+ if registry_config.local?
22
15
  "kamal-local-registry-#{driver}"
23
16
  else
24
17
  "kamal-local-#{driver}"
@@ -34,13 +34,17 @@ class Kamal::Commands::Builder::Remote < Kamal::Commands::Builder::Base
34
34
 
35
35
  private
36
36
  def builder_name
37
- "kamal-remote-#{remote.gsub(/[^a-z0-9_-]/, "-")}"
37
+ "kamal-remote-#{remote_builder_name_suffix}"
38
38
  end
39
39
 
40
40
  def remote_context_name
41
41
  "#{builder_name}-context"
42
42
  end
43
43
 
44
+ def remote_builder_name_suffix
45
+ "#{remote.gsub(/[^a-z0-9_-]/, "-")}#{registry_config.local? ? "-local-registry" : "" }"
46
+ end
47
+
44
48
  def inspect_buildx
45
49
  pipe \
46
50
  docker(:buildx, :inspect, builder_name),
@@ -62,7 +66,7 @@ class Kamal::Commands::Builder::Remote < Kamal::Commands::Builder::Base
62
66
  end
63
67
 
64
68
  def create_buildx
65
- docker :buildx, :create, "--name", builder_name, remote_context_name
69
+ docker :buildx, :create, "--name", builder_name, *driver_options, remote_context_name
66
70
  end
67
71
 
68
72
  def remove_buildx
@@ -177,8 +177,15 @@ class Kamal::Configuration::Builder
177
177
  [ server, cache_image ].compact.join("/")
178
178
  end
179
179
 
180
+ def cache_options
181
+ builder_config["cache"]&.fetch("options", nil)
182
+ end
183
+
180
184
  def cache_from_config_for_gha
181
- "type=gha"
185
+ individual_options = cache_options&.split(",") || []
186
+ allowed_options = individual_options.select { |option| option =~ /^(url|url_v2|token|scope|timeout)=/ }
187
+
188
+ [ "type=gha", *allowed_options ].compact.join(",")
182
189
  end
183
190
 
184
191
  def cache_from_config_for_registry
@@ -186,11 +193,11 @@ class Kamal::Configuration::Builder
186
193
  end
187
194
 
188
195
  def cache_to_config_for_gha
189
- [ "type=gha", builder_config["cache"]&.fetch("options", nil) ].compact.join(",")
196
+ [ "type=gha", cache_options ].compact.join(",")
190
197
  end
191
198
 
192
199
  def cache_to_config_for_registry
193
- [ "type=registry", "ref=#{cache_image_ref}", builder_config["cache"]&.fetch("options", nil) ].compact.join(",")
200
+ [ "type=registry", "ref=#{cache_image_ref}", cache_options ].compact.join(",")
194
201
  end
195
202
 
196
203
  def repo_basename
@@ -82,6 +82,12 @@ asset_path: /path/to/assets
82
82
  # See https://kamal-deploy.org/docs/hooks for more information:
83
83
  hooks_path: /user_home/kamal/hooks
84
84
 
85
+ # Secrets path
86
+ #
87
+ # Path to secrets, defaults to `.kamal/secrets`.
88
+ # Kamal will look for `<secrets_path>-common` and `<secrets_path>` (or `<secrets_path>.<destination>` when using destinations):
89
+ secrets_path: /user_home/kamal/secrets
90
+
85
91
  # Error pages
86
92
  #
87
93
  # A directory relative to the app root to find error pages for the proxy to serve.
@@ -1,19 +1,27 @@
1
1
  # Registry
2
2
  #
3
3
  # The default registry is Docker Hub, but you can change it using `registry/server`.
4
+
5
+ # Using a local container registry
6
+ #
7
+ # If the registry server starts with `localhost`, Kamal will start a local Docker registry
8
+ # on that port and push the app image to it.
9
+ registry:
10
+ server: localhost:5555
11
+
12
+ # Using Docker Hub as the container registry
4
13
  #
5
14
  # By default, Docker Hub creates public repositories. To avoid making your images public,
6
15
  # set up a private repository before deploying, or change the default repository privacy
7
16
  # settings to private in your [Docker Hub settings](https://hub.docker.com/repository-settings/default-privacy).
8
17
  #
9
- # A reference to a secret (in this case, `DOCKER_REGISTRY_TOKEN`) will look up the secret
18
+ # A reference to a secret (in this case, `KAMAL_REGISTRY_PASSWORD`) will look up the secret
10
19
  # in the local environment:
11
20
  registry:
12
- server: registry.digitalocean.com
13
21
  username:
14
- - DOCKER_REGISTRY_TOKEN
22
+ - <your docker hub username>
15
23
  password:
16
- - DOCKER_REGISTRY_TOKEN
24
+ - KAMAL_REGISTRY_PASSWORD
17
25
 
18
26
  # Using AWS ECR as the container registry
19
27
  #
@@ -67,4 +67,4 @@ ssh:
67
67
  # Set to true to load the default OpenSSH config files (~/.ssh/config,
68
68
  # /etc/ssh_config), to false ignore config files, or to a file path
69
69
  # (or array of paths) to load specific configuration. Defaults to true.
70
- config: true
70
+ config: [ "~/.ssh/myconfig" ]
@@ -21,3 +21,11 @@ sshkit:
21
21
  # Kamal sets a long idle timeout of 900 seconds on connections to try to avoid
22
22
  # re-connection storms after an idle period, such as building an image or waiting for CI.
23
23
  pool_idle_timeout: 300
24
+
25
+ # DNS retry settings
26
+ #
27
+ # Some resolvers (mDNSResponder, systemd-resolved, Tailscale) can drop lookups during
28
+ # bursts of concurrent SSH starts. Kamal will retry DNS failures automatically.
29
+ #
30
+ # Number of retries after the initial attempt. Set to 0 to disable.
31
+ dns_retries: 3
@@ -36,7 +36,7 @@ class Kamal::Configuration::Role
36
36
  end
37
37
 
38
38
  def env_tags(host)
39
- tagged_hosts.fetch(host).collect { |tag| config.env_tag(tag) }
39
+ tagged_hosts.fetch(host).collect { |tag| config.env_tag(tag) }.compact
40
40
  end
41
41
 
42
42
  def cmd
@@ -38,8 +38,12 @@ class Kamal::Configuration::Ssh
38
38
  ssh_config["key_data"]
39
39
  end
40
40
 
41
+ def config
42
+ ssh_config["config"]
43
+ end
44
+
41
45
  def options
42
- { user: user, port: port, proxy: proxy, logger: logger, keepalive: true, keepalive_interval: 30, keys_only: keys_only, keys: keys, key_data: key_data }.compact
46
+ { user: user, port: port, proxy: proxy, logger: logger, keepalive: true, keepalive_interval: 30, keys_only: keys_only, keys: keys, key_data: key_data, config: config }.compact
43
47
  end
44
48
 
45
49
  def to_h
@@ -16,6 +16,10 @@ class Kamal::Configuration::Sshkit
16
16
  sshkit_config.fetch("pool_idle_timeout", 900)
17
17
  end
18
18
 
19
+ def dns_retries
20
+ Integer(sshkit_config.fetch("dns_retries", 3))
21
+ end
22
+
19
23
  def to_h
20
24
  sshkit_config
21
25
  end
@@ -34,6 +34,8 @@ class Kamal::Configuration::Validator
34
34
  elsif example_value.is_a?(Array)
35
35
  if key == "arch"
36
36
  validate_array_of_or_type! value, example_value.first.class
37
+ elsif key.to_s == "config"
38
+ validate_ssh_config!(value)
37
39
  else
38
40
  validate_array_of! value, example_value.first.class
39
41
  end
@@ -129,6 +131,16 @@ class Kamal::Configuration::Validator
129
131
  end
130
132
  end
131
133
 
134
+ def validate_ssh_config!(config)
135
+ if config.is_a?(Array)
136
+ validate_array_of! config, String
137
+ elsif boolean?(config.class) || config.is_a?(String)
138
+ # Booleans and Strings are allowed
139
+ else
140
+ type_error(TrueClass, FalseClass, String, Array)
141
+ end
142
+ end
143
+
132
144
  def validate_type!(value, *types)
133
145
  type_error(*types) unless types.any? { |type| valid_type?(value, type) }
134
146
  end
@@ -138,7 +150,8 @@ class Kamal::Configuration::Validator
138
150
  end
139
151
 
140
152
  def type_error(*expected_types)
141
- error "should be #{expected_types.map { |type| type_description(type) }.join(" or ")}"
153
+ descriptions = expected_types.map { |type| type_description(type) }.uniq
154
+ error "should be #{descriptions.join(" or ")}"
142
155
  end
143
156
 
144
157
  def unknown_keys_error(unknown_keys)
@@ -50,7 +50,7 @@ class Kamal::Configuration
50
50
 
51
51
  validate! raw_config, example: validation_yml.symbolize_keys, context: "", with: Kamal::Configuration::Validator::Configuration
52
52
 
53
- @secrets = Kamal::Secrets.new(destination: destination)
53
+ @secrets = Kamal::Secrets.new(destination: destination, secrets_path: secrets_path)
54
54
 
55
55
  # Eager load config to validate it, these are first as they have dependencies later on
56
56
  @servers = Servers.new(config: self)
@@ -76,6 +76,7 @@ class Kamal::Configuration
76
76
  ensure_no_traefik_reboot_hooks
77
77
  ensure_one_host_for_ssl_roles
78
78
  ensure_unique_hosts_for_ssl_roles
79
+ ensure_local_registry_remote_builder_has_ssh_url
79
80
  end
80
81
 
81
82
  def version=(version)
@@ -240,6 +241,10 @@ class Kamal::Configuration
240
241
  raw_config.hooks_path || ".kamal/hooks"
241
242
  end
242
243
 
244
+ def secrets_path
245
+ raw_config.secrets_path || ".kamal/secrets"
246
+ end
247
+
243
248
  def asset_path
244
249
  raw_config.asset_path
245
250
  end
@@ -363,6 +368,16 @@ class Kamal::Configuration
363
368
  true
364
369
  end
365
370
 
371
+ def ensure_local_registry_remote_builder_has_ssh_url
372
+ if registry.local? && builder.remote?
373
+ unless URI(builder.remote).scheme == "ssh"
374
+ raise Kamal::ConfigurationError, "Local registry with remote builder requires an SSH URL (e.g., ssh://user@host)"
375
+ end
376
+ end
377
+
378
+ true
379
+ end
380
+
366
381
  def role_names
367
382
  raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
368
383
  end
@@ -47,9 +47,8 @@ class Kamal::Secrets::Adapters::Passbolt < Kamal::Secrets::Adapters::Base
47
47
  end
48
48
 
49
49
  filter_condition = filter_conditions.any? ? "--filter '#{filter_conditions.join(" || ")}'" : ""
50
- items = `passbolt list resources #{filter_condition} #{folders.map { |item| "--folder #{item["id"]}" }.join(" ")} --json`
50
+ items = `passbolt list resources #{filter_condition} #{folders.map { |item| "--folder #{item["id"].to_s.shellescape}" }.join(" ")} --json`
51
51
  raise RuntimeError, "Could not read #{secrets} from Passbolt" unless $?.success?
52
-
53
52
  items = JSON.parse(items)
54
53
  found_names = items.map { |item| item["name"] }
55
54
  missing_secrets = secret_names - found_names
data/lib/kamal/secrets.rb CHANGED
@@ -3,8 +3,9 @@ require "dotenv"
3
3
  class Kamal::Secrets
4
4
  Kamal::Secrets::Dotenv::InlineCommandSubstitution.install!
5
5
 
6
- def initialize(destination: nil)
6
+ def initialize(destination: nil, secrets_path:)
7
7
  @destination = destination
8
+ @secrets_path = secrets_path
8
9
  @mutex = Mutex.new
9
10
  end
10
11
 
@@ -37,6 +38,6 @@ class Kamal::Secrets
37
38
  end
38
39
 
39
40
  def secrets_filenames
40
- [ ".kamal/secrets-common", ".kamal/secrets#{(".#{@destination}" if @destination)}" ]
41
+ [ "#{@secrets_path}-common", "#{@secrets_path}#{(".#{@destination}" if @destination)}" ]
41
42
  end
42
43
  end
@@ -3,6 +3,7 @@ require "sshkit/dsl"
3
3
  require "net/scp"
4
4
  require "active_support/core_ext/hash/deep_merge"
5
5
  require "json"
6
+ require "resolv"
6
7
  require "concurrent/atomic/semaphore"
7
8
 
8
9
  class SSHKit::Backend::Abstract
@@ -18,8 +19,11 @@ class SSHKit::Backend::Abstract
18
19
  JSON.pretty_generate(JSON.parse(capture(*args, **kwargs)))
19
20
  end
20
21
 
21
- def puts_by_host(host, output, type: "App")
22
- puts "#{type} Host: #{host}\n#{output}\n\n"
22
+ def puts_by_host(host, output, type: "App", quiet: false)
23
+ unless quiet
24
+ puts "#{type} Host: #{host}"
25
+ end
26
+ puts "#{output}\n\n"
23
27
  end
24
28
 
25
29
  # Our execution pattern is for the CLI execute args lists returned
@@ -58,10 +62,50 @@ class SSHKit::Backend::Abstract
58
62
  end
59
63
 
60
64
  class SSHKit::Backend::Netssh::Configuration
61
- attr_accessor :max_concurrent_starts
65
+ attr_accessor :max_concurrent_starts, :dns_retries
62
66
  end
63
67
 
64
68
  class SSHKit::Backend::Netssh
69
+ module DnsRetriable
70
+ DNS_RETRY_BASE = 0.1
71
+ DNS_RETRY_MAX = 2.0
72
+ DNS_RETRY_JITTER = 0.1
73
+ DNS_ERROR_MESSAGE = /getaddrinfo|Temporary failure in name resolution|Name or service not known|nodename nor servname provided|No address associated|failed to look up|resolve/i
74
+
75
+ def with_dns_retry(hostname, retries: config.dns_retries, base: DNS_RETRY_BASE, max_sleep: DNS_RETRY_MAX, jitter: DNS_RETRY_JITTER)
76
+ attempts = 0
77
+ begin
78
+ attempts += 1
79
+ yield
80
+ rescue => error
81
+ raise unless retryable_dns_error?(error) && attempts <= retries
82
+
83
+ delay = dns_retry_sleep(attempts, base: base, jitter: jitter, max_sleep: max_sleep)
84
+ SSHKit.config.output.warn("Retrying DNS for #{hostname} (attempt #{attempts}/#{retries}) in #{format("%0.2f", delay)}s: #{error.message}")
85
+ sleep delay
86
+ retry
87
+ end
88
+ end
89
+
90
+ private
91
+ def retryable_dns_error?(error)
92
+ case error
93
+ when Resolv::ResolvError, Resolv::ResolvTimeout
94
+ true
95
+ when SocketError
96
+ error.message =~ DNS_ERROR_MESSAGE
97
+ else
98
+ error.cause && retryable_dns_error?(error.cause)
99
+ end
100
+ end
101
+
102
+ def dns_retry_sleep(attempt, base:, jitter:, max_sleep:)
103
+ sleep_for = [ base * (2 ** (attempt - 1)), max_sleep ].min
104
+ sleep_for += Kernel.rand * jitter
105
+ sleep_for
106
+ end
107
+ end
108
+
65
109
  module LimitConcurrentStartsClass
66
110
  attr_reader :start_semaphore
67
111
 
@@ -76,14 +120,31 @@ class SSHKit::Backend::Netssh
76
120
 
77
121
  class << self
78
122
  prepend LimitConcurrentStartsClass
123
+ prepend DnsRetriable
79
124
  end
80
125
 
126
+ module ConnectSsh
127
+ private
128
+ def connect_ssh(...)
129
+ Net::SSH.start(...)
130
+ end
131
+ end
132
+ include ConnectSsh
133
+
134
+ module DnsRetriableConnection
135
+ private
136
+ def connect_ssh(...)
137
+ self.class.with_dns_retry(host.hostname) { super }
138
+ end
139
+ end
140
+ prepend DnsRetriableConnection
141
+
81
142
  module LimitConcurrentStartsInstance
82
143
  private
83
144
  def with_ssh(&block)
84
145
  host.ssh_options = self.class.config.ssh_options.merge(host.ssh_options || {})
85
146
  self.class.pool.with(
86
- method(:start_with_concurrency_limit),
147
+ method(:connect_ssh),
87
148
  String(host.hostname),
88
149
  host.username,
89
150
  host.netssh_options,
@@ -91,17 +152,18 @@ class SSHKit::Backend::Netssh
91
152
  )
92
153
  end
93
154
 
94
- def start_with_concurrency_limit(*args)
155
+ def connect_ssh(...)
156
+ with_concurrency_limit { super }
157
+ end
158
+
159
+ def with_concurrency_limit(&block)
95
160
  if self.class.start_semaphore
96
- self.class.start_semaphore.acquire do
97
- Net::SSH.start(*args)
98
- end
161
+ self.class.start_semaphore.acquire(&block)
99
162
  else
100
- Net::SSH.start(*args)
163
+ yield
101
164
  end
102
165
  end
103
166
  end
104
-
105
167
  prepend LimitConcurrentStartsInstance
106
168
  end
107
169
 
data/lib/kamal/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kamal
2
- VERSION = "2.8.1"
2
+ VERSION = "2.9.0"
3
3
  end
data/lib/kamal.rb CHANGED
@@ -7,6 +7,7 @@ require "zeitwerk"
7
7
  require "yaml"
8
8
  require "tmpdir"
9
9
  require "pathname"
10
+ require "uri"
10
11
 
11
12
  loader = Zeitwerk::Loader.for_gem
12
13
  loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb"))
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kamal
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.8.1
4
+ version: 2.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Heinemeier Hansson
@@ -224,12 +224,12 @@ files:
224
224
  - lib/kamal/cli/base.rb
225
225
  - lib/kamal/cli/build.rb
226
226
  - lib/kamal/cli/build/clone.rb
227
+ - lib/kamal/cli/build/port_forwarding.rb
227
228
  - lib/kamal/cli/healthcheck/barrier.rb
228
229
  - lib/kamal/cli/healthcheck/error.rb
229
230
  - lib/kamal/cli/healthcheck/poller.rb
230
231
  - lib/kamal/cli/lock.rb
231
232
  - lib/kamal/cli/main.rb
232
- - lib/kamal/cli/port_forwarding.rb
233
233
  - lib/kamal/cli/proxy.rb
234
234
  - lib/kamal/cli/prune.rb
235
235
  - lib/kamal/cli/registry.rb
@@ -1,55 +0,0 @@
1
- require "concurrent/atomic/count_down_latch"
2
-
3
- class Kamal::Cli::PortForwarding
4
- attr_reader :hosts, :port
5
-
6
- def initialize(hosts, port)
7
- @hosts = hosts
8
- @port = port
9
- end
10
-
11
- def forward
12
- @done = false
13
- forward_ports
14
-
15
- yield
16
- ensure
17
- stop
18
- end
19
-
20
- private
21
-
22
- def stop
23
- @done = true
24
- @threads.to_a.each(&:join)
25
- end
26
-
27
- def forward_ports
28
- ready = Concurrent::CountDownLatch.new(hosts.size)
29
-
30
- @threads = hosts.map do |host|
31
- Thread.new do
32
- Net::SSH.start(host, KAMAL.config.ssh.user, **{ proxy: KAMAL.config.ssh.proxy }.compact) do |ssh|
33
- ssh.forward.remote(port, "localhost", port, "127.0.0.1") do |remote_port, bind_address|
34
- if remote_port == :error
35
- raise "Failed to establish port forward on #{host}"
36
- else
37
- ready.count_down
38
- end
39
- end
40
-
41
- ssh.loop(0.1) do
42
- if @done
43
- ssh.forward.cancel_remote(port, "127.0.0.1")
44
- break
45
- else
46
- true
47
- end
48
- end
49
- end
50
- end
51
- end
52
-
53
- raise "Timed out waiting for port forwarding to be established" unless ready.wait(10)
54
- end
55
- end