kamal 0.16.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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +1021 -0
  4. data/bin/kamal +18 -0
  5. data/lib/kamal/cli/accessory.rb +239 -0
  6. data/lib/kamal/cli/app.rb +296 -0
  7. data/lib/kamal/cli/base.rb +171 -0
  8. data/lib/kamal/cli/build.rb +106 -0
  9. data/lib/kamal/cli/healthcheck.rb +20 -0
  10. data/lib/kamal/cli/lock.rb +37 -0
  11. data/lib/kamal/cli/main.rb +249 -0
  12. data/lib/kamal/cli/prune.rb +30 -0
  13. data/lib/kamal/cli/registry.rb +18 -0
  14. data/lib/kamal/cli/server.rb +21 -0
  15. data/lib/kamal/cli/templates/deploy.yml +74 -0
  16. data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +14 -0
  17. data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +51 -0
  18. data/lib/kamal/cli/templates/sample_hooks/pre-connect.sample +47 -0
  19. data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +109 -0
  20. data/lib/kamal/cli/templates/template.env +2 -0
  21. data/lib/kamal/cli/traefik.rb +111 -0
  22. data/lib/kamal/cli.rb +7 -0
  23. data/lib/kamal/commander.rb +154 -0
  24. data/lib/kamal/commands/accessory.rb +113 -0
  25. data/lib/kamal/commands/app.rb +175 -0
  26. data/lib/kamal/commands/auditor.rb +28 -0
  27. data/lib/kamal/commands/base.rb +65 -0
  28. data/lib/kamal/commands/builder/base.rb +60 -0
  29. data/lib/kamal/commands/builder/multiarch/remote.rb +51 -0
  30. data/lib/kamal/commands/builder/multiarch.rb +29 -0
  31. data/lib/kamal/commands/builder/native/cached.rb +16 -0
  32. data/lib/kamal/commands/builder/native/remote.rb +59 -0
  33. data/lib/kamal/commands/builder/native.rb +20 -0
  34. data/lib/kamal/commands/builder.rb +62 -0
  35. data/lib/kamal/commands/docker.rb +21 -0
  36. data/lib/kamal/commands/healthcheck.rb +57 -0
  37. data/lib/kamal/commands/hook.rb +14 -0
  38. data/lib/kamal/commands/lock.rb +63 -0
  39. data/lib/kamal/commands/prune.rb +38 -0
  40. data/lib/kamal/commands/registry.rb +20 -0
  41. data/lib/kamal/commands/traefik.rb +104 -0
  42. data/lib/kamal/commands.rb +2 -0
  43. data/lib/kamal/configuration/accessory.rb +169 -0
  44. data/lib/kamal/configuration/boot.rb +20 -0
  45. data/lib/kamal/configuration/builder.rb +114 -0
  46. data/lib/kamal/configuration/role.rb +155 -0
  47. data/lib/kamal/configuration/ssh.rb +38 -0
  48. data/lib/kamal/configuration/sshkit.rb +20 -0
  49. data/lib/kamal/configuration.rb +251 -0
  50. data/lib/kamal/sshkit_with_ext.rb +104 -0
  51. data/lib/kamal/tags.rb +39 -0
  52. data/lib/kamal/utils/healthcheck_poller.rb +39 -0
  53. data/lib/kamal/utils/sensitive.rb +19 -0
  54. data/lib/kamal/utils.rb +100 -0
  55. data/lib/kamal/version.rb +3 -0
  56. data/lib/kamal.rb +10 -0
  57. metadata +266 -0
data/bin/kamal ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Prevent failures from being reported twice.
4
+ Thread.report_on_exception = false
5
+
6
+ require "kamal"
7
+
8
+ begin
9
+ Kamal::Cli::Main.start(ARGV)
10
+ rescue SSHKit::Runner::ExecuteError => e
11
+ puts " \e[31mERROR (#{e.cause.class}): #{e.message}\e[0m"
12
+ puts e.cause.backtrace if ENV["VERBOSE"]
13
+ exit 1
14
+ rescue => e
15
+ puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m"
16
+ puts e.backtrace if ENV["VERBOSE"]
17
+ exit 1
18
+ end
@@ -0,0 +1,239 @@
1
+ class Kamal::Cli::Accessory < Kamal::Cli::Base
2
+ desc "boot [NAME]", "Boot new accessory service on host (use NAME=all to boot all accessories)"
3
+ def boot(name, login: true)
4
+ mutating do
5
+ if name == "all"
6
+ KAMAL.accessory_names.each { |accessory_name| boot(accessory_name) }
7
+ else
8
+ with_accessory(name) do |accessory|
9
+ directories(name)
10
+ upload(name)
11
+
12
+ on(accessory.hosts) do
13
+ execute *KAMAL.registry.login if login
14
+ execute *KAMAL.auditor.record("Booted #{name} accessory"), verbosity: :debug
15
+ execute *accessory.run
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ desc "upload [NAME]", "Upload accessory files to host", hide: true
23
+ def upload(name)
24
+ mutating do
25
+ with_accessory(name) do |accessory|
26
+ on(accessory.hosts) do
27
+ accessory.files.each do |(local, remote)|
28
+ accessory.ensure_local_file_present(local)
29
+
30
+ execute *accessory.make_directory_for(remote)
31
+ upload! local, remote
32
+ execute :chmod, "755", remote
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ desc "directories [NAME]", "Create accessory directories on host", hide: true
40
+ def directories(name)
41
+ mutating do
42
+ with_accessory(name) do |accessory|
43
+ on(accessory.hosts) do
44
+ accessory.directories.keys.each do |host_path|
45
+ execute *accessory.make_directory(host_path)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ desc "reboot [NAME]", "Reboot existing accessory on host (stop container, remove container, start new container)"
53
+ def reboot(name)
54
+ mutating do
55
+ with_accessory(name) do |accessory|
56
+ on(accessory.hosts) do
57
+ execute *KAMAL.registry.login
58
+ end
59
+
60
+ stop(name)
61
+ remove_container(name)
62
+ boot(name, login: false)
63
+ end
64
+ end
65
+ end
66
+
67
+ desc "start [NAME]", "Start existing accessory container on host"
68
+ def start(name)
69
+ mutating do
70
+ with_accessory(name) do |accessory|
71
+ on(accessory.hosts) do
72
+ execute *KAMAL.auditor.record("Started #{name} accessory"), verbosity: :debug
73
+ execute *accessory.start
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ desc "stop [NAME]", "Stop existing accessory container on host"
80
+ def stop(name)
81
+ mutating do
82
+ with_accessory(name) do |accessory|
83
+ on(accessory.hosts) do
84
+ execute *KAMAL.auditor.record("Stopped #{name} accessory"), verbosity: :debug
85
+ execute *accessory.stop, raise_on_non_zero_exit: false
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ desc "restart [NAME]", "Restart existing accessory container on host"
92
+ def restart(name)
93
+ mutating do
94
+ with_accessory(name) do
95
+ stop(name)
96
+ start(name)
97
+ end
98
+ end
99
+ end
100
+
101
+ desc "details [NAME]", "Show details about accessory on host (use NAME=all to show all accessories)"
102
+ def details(name)
103
+ if name == "all"
104
+ KAMAL.accessory_names.each { |accessory_name| details(accessory_name) }
105
+ else
106
+ with_accessory(name) do |accessory|
107
+ on(accessory.hosts) { puts capture_with_info(*accessory.info) }
108
+ end
109
+ end
110
+ end
111
+
112
+ desc "exec [NAME] [CMD]", "Execute a custom command on servers (use --help to show options)"
113
+ option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
114
+ option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
115
+ def exec(name, cmd)
116
+ with_accessory(name) do |accessory|
117
+ case
118
+ when options[:interactive] && options[:reuse]
119
+ say "Launching interactive command with via SSH from existing container...", :magenta
120
+ run_locally { exec accessory.execute_in_existing_container_over_ssh(cmd) }
121
+
122
+ when options[:interactive]
123
+ say "Launching interactive command via SSH from new container...", :magenta
124
+ run_locally { exec accessory.execute_in_new_container_over_ssh(cmd) }
125
+
126
+ when options[:reuse]
127
+ say "Launching command from existing container...", :magenta
128
+ on(accessory.hosts) do
129
+ execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
130
+ capture_with_info(*accessory.execute_in_existing_container(cmd))
131
+ end
132
+
133
+ else
134
+ say "Launching command from new container...", :magenta
135
+ on(accessory.hosts) do
136
+ execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
137
+ capture_with_info(*accessory.execute_in_new_container(cmd))
138
+ end
139
+ end
140
+ end
141
+ end
142
+
143
+ desc "logs [NAME]", "Show log lines from accessory on host (use --help to show options)"
144
+ option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)"
145
+ option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server"
146
+ option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)"
147
+ option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)"
148
+ def logs(name)
149
+ with_accessory(name) do |accessory|
150
+ grep = options[:grep]
151
+
152
+ if options[:follow]
153
+ run_locally do
154
+ info "Following logs on #{accessory.hosts}..."
155
+ info accessory.follow_logs(grep: grep)
156
+ exec accessory.follow_logs(grep: grep)
157
+ end
158
+ else
159
+ since = options[:since]
160
+ lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
161
+
162
+ on(accessory.hosts) do
163
+ puts capture_with_info(*accessory.logs(since: since, lines: lines, grep: grep))
164
+ end
165
+ end
166
+ end
167
+ end
168
+
169
+ desc "remove [NAME]", "Remove accessory container, image and data directory from host (use NAME=all to remove all accessories)"
170
+ option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
171
+ def remove(name)
172
+ mutating do
173
+ if name == "all"
174
+ KAMAL.accessory_names.each { |accessory_name| remove(accessory_name) }
175
+ else
176
+ if options[:confirmed] || ask("This will remove all containers, images and data directories for #{name}. Are you sure?", limited_to: %w( y N ), default: "N") == "y"
177
+ with_accessory(name) do
178
+ stop(name)
179
+ remove_container(name)
180
+ remove_image(name)
181
+ remove_service_directory(name)
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
187
+
188
+ desc "remove_container [NAME]", "Remove accessory container from host", hide: true
189
+ def remove_container(name)
190
+ mutating do
191
+ with_accessory(name) do |accessory|
192
+ on(accessory.hosts) do
193
+ execute *KAMAL.auditor.record("Remove #{name} accessory container"), verbosity: :debug
194
+ execute *accessory.remove_container
195
+ end
196
+ end
197
+ end
198
+ end
199
+
200
+ desc "remove_image [NAME]", "Remove accessory image from host", hide: true
201
+ def remove_image(name)
202
+ mutating do
203
+ with_accessory(name) do |accessory|
204
+ on(accessory.hosts) do
205
+ execute *KAMAL.auditor.record("Removed #{name} accessory image"), verbosity: :debug
206
+ execute *accessory.remove_image
207
+ end
208
+ end
209
+ end
210
+ end
211
+
212
+ desc "remove_service_directory [NAME]", "Remove accessory directory used for uploaded files and data directories from host", hide: true
213
+ def remove_service_directory(name)
214
+ mutating do
215
+ with_accessory(name) do |accessory|
216
+ on(accessory.hosts) do
217
+ execute *accessory.remove_service_directory
218
+ end
219
+ end
220
+ end
221
+ end
222
+
223
+ private
224
+ def with_accessory(name)
225
+ if accessory = KAMAL.accessory(name)
226
+ yield accessory
227
+ else
228
+ error_on_missing_accessory(name)
229
+ end
230
+ end
231
+
232
+ def error_on_missing_accessory(name)
233
+ options = KAMAL.accessory_names.presence
234
+
235
+ error \
236
+ "No accessory by the name of '#{name}'" +
237
+ (options ? " (options: #{options.to_sentence})" : "")
238
+ end
239
+ end
@@ -0,0 +1,296 @@
1
+ class Kamal::Cli::App < Kamal::Cli::Base
2
+ desc "boot", "Boot app on servers (or reboot app if already running)"
3
+ def boot
4
+ mutating do
5
+ hold_lock_on_error do
6
+ say "Get most recent version available as an image...", :magenta unless options[:version]
7
+ using_version(version_or_latest) do |version|
8
+ say "Start container with version #{version} using a #{KAMAL.config.readiness_delay}s readiness delay (or reboot if already running)...", :magenta
9
+
10
+ on(KAMAL.hosts) do
11
+ execute *KAMAL.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug
12
+ execute *KAMAL.app.tag_current_as_latest
13
+ end
14
+
15
+ on(KAMAL.hosts, **KAMAL.boot_strategy) do |host|
16
+ roles = KAMAL.roles_on(host)
17
+
18
+ roles.each do |role|
19
+ app = KAMAL.app(role: role)
20
+ auditor = KAMAL.auditor(role: role)
21
+
22
+ if capture_with_info(*app.container_id_for_version(version, only_running: true), raise_on_non_zero_exit: false).present?
23
+ tmp_version = "#{version}_replaced_#{SecureRandom.hex(8)}"
24
+ info "Renaming container #{version} to #{tmp_version} as already deployed on #{host}"
25
+ execute *auditor.record("Renaming container #{version} to #{tmp_version}"), verbosity: :debug
26
+ execute *app.rename_container(version: version, new_version: tmp_version)
27
+ end
28
+
29
+ execute *auditor.record("Booted app version #{version}"), verbosity: :debug
30
+
31
+ old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
32
+ execute *app.start_or_run(hostname: "#{host}-#{SecureRandom.hex(6)}")
33
+
34
+ Kamal::Utils::HealthcheckPoller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) }
35
+
36
+ execute *app.stop(version: old_version), raise_on_non_zero_exit: false if old_version.present?
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ desc "start", "Start existing app container on servers"
45
+ def start
46
+ mutating do
47
+ on(KAMAL.hosts) do |host|
48
+ roles = KAMAL.roles_on(host)
49
+
50
+ roles.each do |role|
51
+ execute *KAMAL.auditor.record("Started app version #{KAMAL.config.version}"), verbosity: :debug
52
+ execute *KAMAL.app(role: role).start, raise_on_non_zero_exit: false
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ desc "stop", "Stop app container on servers"
59
+ def stop
60
+ mutating do
61
+ on(KAMAL.hosts) do |host|
62
+ roles = KAMAL.roles_on(host)
63
+
64
+ roles.each do |role|
65
+ execute *KAMAL.auditor.record("Stopped app", role: role), verbosity: :debug
66
+ execute *KAMAL.app(role: role).stop, raise_on_non_zero_exit: false
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ # FIXME: Drop in favor of just containers?
73
+ desc "details", "Show details about app containers"
74
+ def details
75
+ on(KAMAL.hosts) do |host|
76
+ roles = KAMAL.roles_on(host)
77
+
78
+ roles.each do |role|
79
+ puts_by_host host, capture_with_info(*KAMAL.app(role: role).info)
80
+ end
81
+ end
82
+ end
83
+
84
+ desc "exec [CMD]", "Execute a custom command on servers (use --help to show options)"
85
+ option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
86
+ option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
87
+ def exec(cmd)
88
+ case
89
+ when options[:interactive] && options[:reuse]
90
+ say "Get current version of running container...", :magenta unless options[:version]
91
+ using_version(options[:version] || current_running_version) do |version|
92
+ say "Launching interactive command with version #{version} via SSH from existing container on #{KAMAL.primary_host}...", :magenta
93
+ run_locally { exec KAMAL.app(role: "web").execute_in_existing_container_over_ssh(cmd, host: KAMAL.primary_host) }
94
+ end
95
+
96
+ when options[:interactive]
97
+ say "Get most recent version available as an image...", :magenta unless options[:version]
98
+ using_version(version_or_latest) do |version|
99
+ say "Launching interactive command with version #{version} via SSH from new container on #{KAMAL.primary_host}...", :magenta
100
+ run_locally { exec KAMAL.app(role: KAMAL.primary_host.roles.first).execute_in_new_container_over_ssh(cmd, host: KAMAL.primary_host) }
101
+ end
102
+
103
+ when options[:reuse]
104
+ say "Get current version of running container...", :magenta unless options[:version]
105
+ using_version(options[:version] || current_running_version) do |version|
106
+ say "Launching command with version #{version} from existing container...", :magenta
107
+
108
+ on(KAMAL.hosts) do |host|
109
+ roles = KAMAL.roles_on(host)
110
+
111
+ roles.each do |role|
112
+ execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}", role: role), verbosity: :debug
113
+ puts_by_host host, capture_with_info(*KAMAL.app(role: role).execute_in_existing_container(cmd))
114
+ end
115
+ end
116
+ end
117
+
118
+ else
119
+ say "Get most recent version available as an image...", :magenta unless options[:version]
120
+ using_version(version_or_latest) do |version|
121
+ say "Launching command with version #{version} from new container...", :magenta
122
+ on(KAMAL.hosts) do |host|
123
+ execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
124
+ puts_by_host host, capture_with_info(*KAMAL.app.execute_in_new_container(cmd))
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ desc "containers", "Show app containers on servers"
131
+ def containers
132
+ on(KAMAL.hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_containers) }
133
+ end
134
+
135
+ desc "stale_containers", "Detect app stale containers"
136
+ option :stop, aliases: "-s", type: :boolean, default: false, desc: "Stop the stale containers found"
137
+ def stale_containers
138
+ mutating do
139
+ stop = options[:stop]
140
+
141
+ cli = self
142
+
143
+ on(KAMAL.hosts) do |host|
144
+ roles = KAMAL.roles_on(host)
145
+
146
+ roles.each do |role|
147
+ cli.send(:stale_versions, host: host, role: role).each do |version|
148
+ if stop
149
+ puts_by_host host, "Stopping stale container for role #{role} with version #{version}"
150
+ execute *KAMAL.app(role: role).stop(version: version), raise_on_non_zero_exit: false
151
+ else
152
+ puts_by_host host, "Detected stale container for role #{role} with version #{version} (use `kamal app stale_containers --stop` to stop)"
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+
160
+ desc "images", "Show app images on servers"
161
+ def images
162
+ on(KAMAL.hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_images) }
163
+ end
164
+
165
+ desc "logs", "Show log lines from app on servers (use --help to show options)"
166
+ option :since, aliases: "-s", desc: "Show lines since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)"
167
+ option :lines, type: :numeric, aliases: "-n", desc: "Number of lines to show from each server"
168
+ option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)"
169
+ option :follow, aliases: "-f", desc: "Follow log on primary server (or specific host set by --hosts)"
170
+ def logs
171
+ # FIXME: Catch when app containers aren't running
172
+
173
+ grep = options[:grep]
174
+
175
+ if options[:follow]
176
+ run_locally do
177
+ info "Following logs on #{KAMAL.primary_host}..."
178
+
179
+ KAMAL.specific_roles ||= ["web"]
180
+ role = KAMAL.roles_on(KAMAL.primary_host).first
181
+
182
+ info KAMAL.app(role: role).follow_logs(host: KAMAL.primary_host, grep: grep)
183
+ exec KAMAL.app(role: role).follow_logs(host: KAMAL.primary_host, grep: grep)
184
+ end
185
+ else
186
+ since = options[:since]
187
+ lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
188
+
189
+ on(KAMAL.hosts) do |host|
190
+ roles = KAMAL.roles_on(host)
191
+
192
+ roles.each do |role|
193
+ begin
194
+ puts_by_host host, capture_with_info(*KAMAL.app(role: role).logs(since: since, lines: lines, grep: grep))
195
+ rescue SSHKit::Command::Failed
196
+ puts_by_host host, "Nothing found"
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
202
+
203
+ desc "remove", "Remove app containers and images from servers"
204
+ def remove
205
+ mutating do
206
+ stop
207
+ remove_containers
208
+ remove_images
209
+ end
210
+ end
211
+
212
+ desc "remove_container [VERSION]", "Remove app container with given version from servers", hide: true
213
+ def remove_container(version)
214
+ mutating do
215
+ on(KAMAL.hosts) do |host|
216
+ roles = KAMAL.roles_on(host)
217
+
218
+ roles.each do |role|
219
+ execute *KAMAL.auditor.record("Removed app container with version #{version}", role: role), verbosity: :debug
220
+ execute *KAMAL.app(role: role).remove_container(version: version)
221
+ end
222
+ end
223
+ end
224
+ end
225
+
226
+ desc "remove_containers", "Remove all app containers from servers", hide: true
227
+ def remove_containers
228
+ mutating do
229
+ on(KAMAL.hosts) do |host|
230
+ roles = KAMAL.roles_on(host)
231
+
232
+ roles.each do |role|
233
+ execute *KAMAL.auditor.record("Removed all app containers", role: role), verbosity: :debug
234
+ execute *KAMAL.app(role: role).remove_containers
235
+ end
236
+ end
237
+ end
238
+ end
239
+
240
+ desc "remove_images", "Remove all app images from servers", hide: true
241
+ def remove_images
242
+ mutating do
243
+ on(KAMAL.hosts) do
244
+ execute *KAMAL.auditor.record("Removed all app images"), verbosity: :debug
245
+ execute *KAMAL.app.remove_images
246
+ end
247
+ end
248
+ end
249
+
250
+ desc "version", "Show app version currently running on servers"
251
+ def version
252
+ on(KAMAL.hosts) do |host|
253
+ role = KAMAL.roles_on(host).first
254
+ puts_by_host host, capture_with_info(*KAMAL.app(role: role).current_running_version).strip
255
+ end
256
+ end
257
+
258
+ private
259
+ def using_version(new_version)
260
+ if new_version
261
+ begin
262
+ old_version = KAMAL.config.version
263
+ KAMAL.config.version = new_version
264
+ yield new_version
265
+ ensure
266
+ KAMAL.config.version = old_version
267
+ end
268
+ else
269
+ yield KAMAL.config.version
270
+ end
271
+ end
272
+
273
+ def current_running_version(host: KAMAL.primary_host)
274
+ version = nil
275
+ on(host) do
276
+ role = KAMAL.roles_on(host).first
277
+ version = capture_with_info(*KAMAL.app(role: role).current_running_version).strip
278
+ end
279
+ version.presence
280
+ end
281
+
282
+ def stale_versions(host:, role:)
283
+ versions = nil
284
+ on(host) do
285
+ versions = \
286
+ capture_with_info(*KAMAL.app(role: role).list_versions, raise_on_non_zero_exit: false)
287
+ .split("\n")
288
+ .drop(1)
289
+ end
290
+ versions
291
+ end
292
+
293
+ def version_or_latest
294
+ options[:version] || "latest"
295
+ end
296
+ end