dash 2.12.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 (142) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +13 -0
  4. data/bin/dash +18 -0
  5. data/bin/kamal +18 -0
  6. data/lib/kamal/cli/accessory.rb +342 -0
  7. data/lib/kamal/cli/alias/command.rb +10 -0
  8. data/lib/kamal/cli/app/assets.rb +24 -0
  9. data/lib/kamal/cli/app/boot.rb +126 -0
  10. data/lib/kamal/cli/app/error_pages.rb +33 -0
  11. data/lib/kamal/cli/app/ssl_certificates.rb +28 -0
  12. data/lib/kamal/cli/app.rb +368 -0
  13. data/lib/kamal/cli/base.rb +324 -0
  14. data/lib/kamal/cli/build/clone.rb +59 -0
  15. data/lib/kamal/cli/build/port_forwarding.rb +66 -0
  16. data/lib/kamal/cli/build.rb +242 -0
  17. data/lib/kamal/cli/healthcheck/barrier.rb +33 -0
  18. data/lib/kamal/cli/healthcheck/error.rb +2 -0
  19. data/lib/kamal/cli/healthcheck/poller.rb +42 -0
  20. data/lib/kamal/cli/lock.rb +34 -0
  21. data/lib/kamal/cli/main.rb +299 -0
  22. data/lib/kamal/cli/proxy.rb +419 -0
  23. data/lib/kamal/cli/prune.rb +34 -0
  24. data/lib/kamal/cli/registry.rb +49 -0
  25. data/lib/kamal/cli/secrets.rb +50 -0
  26. data/lib/kamal/cli/server.rb +70 -0
  27. data/lib/kamal/cli/templates/deploy.yml +102 -0
  28. data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +3 -0
  29. data/lib/kamal/cli/templates/sample_hooks/post-app-boot.sample +3 -0
  30. data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +14 -0
  31. data/lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample +3 -0
  32. data/lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample +3 -0
  33. data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +51 -0
  34. data/lib/kamal/cli/templates/sample_hooks/pre-connect.sample +47 -0
  35. data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +122 -0
  36. data/lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample +3 -0
  37. data/lib/kamal/cli/templates/secrets +22 -0
  38. data/lib/kamal/cli.rb +9 -0
  39. data/lib/kamal/commander/specifics.rb +62 -0
  40. data/lib/kamal/commander.rb +230 -0
  41. data/lib/kamal/commands/accessory/proxy.rb +16 -0
  42. data/lib/kamal/commands/accessory.rb +118 -0
  43. data/lib/kamal/commands/app/assets.rb +51 -0
  44. data/lib/kamal/commands/app/containers.rb +31 -0
  45. data/lib/kamal/commands/app/error_pages.rb +9 -0
  46. data/lib/kamal/commands/app/execution.rb +38 -0
  47. data/lib/kamal/commands/app/images.rb +13 -0
  48. data/lib/kamal/commands/app/logging.rb +28 -0
  49. data/lib/kamal/commands/app/proxy.rb +32 -0
  50. data/lib/kamal/commands/app.rb +125 -0
  51. data/lib/kamal/commands/auditor.rb +39 -0
  52. data/lib/kamal/commands/base.rb +147 -0
  53. data/lib/kamal/commands/builder/base.rb +143 -0
  54. data/lib/kamal/commands/builder/clone.rb +32 -0
  55. data/lib/kamal/commands/builder/cloud.rb +22 -0
  56. data/lib/kamal/commands/builder/hybrid.rb +21 -0
  57. data/lib/kamal/commands/builder/local.rb +20 -0
  58. data/lib/kamal/commands/builder/pack.rb +46 -0
  59. data/lib/kamal/commands/builder/remote.rb +75 -0
  60. data/lib/kamal/commands/builder.rb +54 -0
  61. data/lib/kamal/commands/docker.rb +50 -0
  62. data/lib/kamal/commands/hook.rb +20 -0
  63. data/lib/kamal/commands/loadbalancer.rb +130 -0
  64. data/lib/kamal/commands/lock.rb +70 -0
  65. data/lib/kamal/commands/proxy.rb +150 -0
  66. data/lib/kamal/commands/prune.rb +38 -0
  67. data/lib/kamal/commands/registry.rb +38 -0
  68. data/lib/kamal/commands/server.rb +15 -0
  69. data/lib/kamal/commands.rb +2 -0
  70. data/lib/kamal/configuration/accessory.rb +280 -0
  71. data/lib/kamal/configuration/alias.rb +15 -0
  72. data/lib/kamal/configuration/boot.rb +29 -0
  73. data/lib/kamal/configuration/builder.rb +218 -0
  74. data/lib/kamal/configuration/docs/accessory.yml +160 -0
  75. data/lib/kamal/configuration/docs/alias.yml +29 -0
  76. data/lib/kamal/configuration/docs/boot.yml +21 -0
  77. data/lib/kamal/configuration/docs/builder.yml +132 -0
  78. data/lib/kamal/configuration/docs/configuration.yml +228 -0
  79. data/lib/kamal/configuration/docs/env.yml +118 -0
  80. data/lib/kamal/configuration/docs/logging.yml +21 -0
  81. data/lib/kamal/configuration/docs/output.yml +25 -0
  82. data/lib/kamal/configuration/docs/proxy.yml +207 -0
  83. data/lib/kamal/configuration/docs/registry.yml +64 -0
  84. data/lib/kamal/configuration/docs/role.yml +54 -0
  85. data/lib/kamal/configuration/docs/servers.yml +27 -0
  86. data/lib/kamal/configuration/docs/ssh.yml +81 -0
  87. data/lib/kamal/configuration/docs/sshkit.yml +31 -0
  88. data/lib/kamal/configuration/env/tag.rb +13 -0
  89. data/lib/kamal/configuration/env.rb +42 -0
  90. data/lib/kamal/configuration/loadbalancer.rb +34 -0
  91. data/lib/kamal/configuration/logging.rb +33 -0
  92. data/lib/kamal/configuration/output.rb +34 -0
  93. data/lib/kamal/configuration/proxy/boot.rb +124 -0
  94. data/lib/kamal/configuration/proxy/run.rb +152 -0
  95. data/lib/kamal/configuration/proxy.rb +156 -0
  96. data/lib/kamal/configuration/registry.rb +40 -0
  97. data/lib/kamal/configuration/role.rb +247 -0
  98. data/lib/kamal/configuration/servers.rb +25 -0
  99. data/lib/kamal/configuration/ssh.rb +76 -0
  100. data/lib/kamal/configuration/sshkit.rb +26 -0
  101. data/lib/kamal/configuration/validation.rb +27 -0
  102. data/lib/kamal/configuration/validator/accessory.rb +13 -0
  103. data/lib/kamal/configuration/validator/alias.rb +15 -0
  104. data/lib/kamal/configuration/validator/builder.rb +15 -0
  105. data/lib/kamal/configuration/validator/configuration.rb +6 -0
  106. data/lib/kamal/configuration/validator/env.rb +54 -0
  107. data/lib/kamal/configuration/validator/proxy.rb +47 -0
  108. data/lib/kamal/configuration/validator/registry.rb +27 -0
  109. data/lib/kamal/configuration/validator/role.rb +13 -0
  110. data/lib/kamal/configuration/validator/servers.rb +7 -0
  111. data/lib/kamal/configuration/validator.rb +251 -0
  112. data/lib/kamal/configuration/volume.rb +29 -0
  113. data/lib/kamal/configuration.rb +465 -0
  114. data/lib/kamal/docker.rb +30 -0
  115. data/lib/kamal/env_file.rb +44 -0
  116. data/lib/kamal/git.rb +37 -0
  117. data/lib/kamal/otel_shipper.rb +176 -0
  118. data/lib/kamal/output/base_logger.rb +29 -0
  119. data/lib/kamal/output/file_logger.rb +51 -0
  120. data/lib/kamal/output/formatter.rb +36 -0
  121. data/lib/kamal/output/otel_logger.rb +70 -0
  122. data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +59 -0
  123. data/lib/kamal/secrets/adapters/base.rb +33 -0
  124. data/lib/kamal/secrets/adapters/bitwarden.rb +81 -0
  125. data/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb +66 -0
  126. data/lib/kamal/secrets/adapters/doppler.rb +57 -0
  127. data/lib/kamal/secrets/adapters/enpass.rb +71 -0
  128. data/lib/kamal/secrets/adapters/gcp_secret_manager.rb +112 -0
  129. data/lib/kamal/secrets/adapters/last_pass.rb +40 -0
  130. data/lib/kamal/secrets/adapters/one_password.rb +104 -0
  131. data/lib/kamal/secrets/adapters/passbolt.rb +129 -0
  132. data/lib/kamal/secrets/adapters/test.rb +16 -0
  133. data/lib/kamal/secrets/adapters.rb +16 -0
  134. data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +47 -0
  135. data/lib/kamal/secrets.rb +53 -0
  136. data/lib/kamal/sshkit_with_ext.rb +273 -0
  137. data/lib/kamal/tags.rb +40 -0
  138. data/lib/kamal/utils/sensitive.rb +20 -0
  139. data/lib/kamal/utils.rb +110 -0
  140. data/lib/kamal/version.rb +3 -0
  141. data/lib/kamal.rb +15 -0
  142. metadata +388 -0
@@ -0,0 +1,368 @@
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
+ modify(lock: true) do
5
+ say "Get most recent version available as an image...", :magenta unless options[:version]
6
+ using_version(version_or_latest) do |version|
7
+ say "Start container with version #{version} (or reboot if already running)...", :magenta
8
+
9
+ # Assets are prepared in a separate step to ensure they are on all hosts before booting
10
+ on(KAMAL.app_hosts) do
11
+ Kamal::Cli::App::ErrorPages.new(host, self).run
12
+
13
+ KAMAL.roles_on(host).each do |role|
14
+ Kamal::Cli::App::Assets.new(host, role, self).run
15
+ Kamal::Cli::App::SslCertificates.new(host, role, self).run
16
+ end
17
+ end
18
+
19
+ # Primary hosts and roles are returned first, so they can open the barrier
20
+ barrier = Kamal::Cli::Healthcheck::Barrier.new
21
+
22
+ host_boot_groups.each do |hosts|
23
+ host_list = Array(hosts).join(",")
24
+ run_hook "pre-app-boot", hosts: host_list
25
+
26
+ on_roles(KAMAL.roles, hosts: hosts, parallel: KAMAL.config.boot.parallel_roles) do |host, role|
27
+ Kamal::Cli::App::Boot.new(host, role, self, version, barrier).run
28
+ end
29
+
30
+ run_hook "post-app-boot", hosts: host_list
31
+ sleep KAMAL.config.boot.wait if KAMAL.config.boot.wait
32
+ end
33
+
34
+ # Tag once the app booted on all hosts
35
+ on(KAMAL.app_hosts) do |host|
36
+ execute *KAMAL.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug
37
+ execute *KAMAL.app.tag_latest_image
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ desc "start", "Start existing app container on servers"
44
+ def start
45
+ modify(lock: true) do
46
+ on_roles(KAMAL.roles, hosts: KAMAL.app_hosts, parallel: KAMAL.config.boot.parallel_roles) do |host, role|
47
+ app = KAMAL.app(role: role, host: host)
48
+ execute *KAMAL.auditor.record("Started app version #{KAMAL.config.version}"), verbosity: :debug
49
+ execute *app.start, raise_on_non_zero_exit: false
50
+
51
+ if role.running_proxy?
52
+ version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
53
+ endpoint = capture_with_info(*app.container_id_for_version(version)).strip
54
+ raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
55
+
56
+ execute *app.deploy(target: endpoint)
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ desc "stop", "Stop app container on servers"
63
+ def stop
64
+ modify(lock: true) do
65
+ on_roles(KAMAL.roles, hosts: KAMAL.app_hosts, parallel: KAMAL.config.boot.parallel_roles) do |host, role|
66
+ app = KAMAL.app(role: role, host: host)
67
+ execute *KAMAL.auditor.record("Stopped app", role: role), verbosity: :debug
68
+
69
+ if role.running_proxy?
70
+ version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
71
+ endpoint = capture_with_info(*app.container_id_for_version(version)).strip
72
+ if endpoint.present?
73
+ execute *app.remove, raise_on_non_zero_exit: false
74
+ end
75
+ end
76
+
77
+ execute *app.stop, raise_on_non_zero_exit: false
78
+ end
79
+ end
80
+ end
81
+
82
+ # FIXME: Drop in favor of just containers?
83
+ desc "details", "Show details about app containers"
84
+ def details
85
+ quiet = options[:quiet]
86
+ on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role|
87
+ puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).info), quiet: quiet
88
+ end
89
+ end
90
+
91
+ desc "exec [CMD...]", "Execute a custom command on servers within the app container (use --help to show options)"
92
+ option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
93
+ option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
94
+ option :env, aliases: "-e", type: :hash, desc: "Set environment variables for the command"
95
+ option :detach, type: :boolean, default: false, desc: "Execute command in a detached container"
96
+ option :raw, type: :boolean, default: false, desc: "Output raw, unmodified stdout"
97
+ def exec(*cmd)
98
+ raw = options[:raw]
99
+
100
+ if (incompatible_options = [ :interactive, :reuse ].select { |key| options[:detach] && options[key] }.presence)
101
+ raise ArgumentError, "Detach is not compatible with #{incompatible_options.join(" or ")}"
102
+ end
103
+
104
+ if raw && (incompatible_options = [ :interactive, :detach ].select { |key| options[key] }.presence)
105
+ raise ArgumentError, "Raw is not compatible with #{incompatible_options.join(" or ")}"
106
+ end
107
+
108
+ if cmd.empty?
109
+ raise ArgumentError, "No command provided. You must specify a command to execute."
110
+ end
111
+
112
+ with_raw_output(raw) do
113
+ pre_connect_if_required
114
+
115
+ cmd = Kamal::Utils.join_commands(cmd)
116
+ env = options[:env]
117
+ detach = options[:detach]
118
+ quiet = options[:quiet]
119
+ case
120
+ when options[:interactive] && options[:reuse]
121
+ say "Get current version of running container...", :magenta unless options[:version]
122
+ using_version(options[:version] || current_running_version) do |version|
123
+ say "Launching interactive command with version #{version} via SSH from existing container on #{KAMAL.primary_host}...", :magenta
124
+ run_locally { exec KAMAL.app(role: KAMAL.primary_role, host: KAMAL.primary_host).execute_in_existing_container_over_ssh(cmd, env: env) }
125
+ end
126
+
127
+ when options[:interactive]
128
+ say "Get most recent version available as an image...", :magenta unless options[:version]
129
+ using_version(version_or_latest) do |version|
130
+ say "Launching interactive command with version #{version} via SSH from new container on #{KAMAL.primary_host}...", :magenta
131
+ on(KAMAL.primary_host) { execute *KAMAL.registry.login }
132
+ run_locally do
133
+ exec KAMAL.app(role: KAMAL.primary_role, host: KAMAL.primary_host).execute_in_new_container_over_ssh(cmd, env: env)
134
+ end
135
+ end
136
+
137
+ when options[:reuse]
138
+ say "Get current version of running container...", :magenta unless options[:version]
139
+ using_version(options[:version] || current_running_version) do |version|
140
+ say "Launching command with version #{version} from existing container...", :magenta
141
+
142
+ on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role|
143
+ execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}", role: role), verbosity: :debug
144
+ puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_existing_container(cmd, env: env), strip: !raw), quiet: quiet, raw: raw
145
+ end
146
+ end
147
+
148
+ else
149
+ say "Get most recent version available as an image...", :magenta unless options[:version]
150
+ using_version(version_or_latest) do |version|
151
+ say "Launching command with version #{version} from new container...", :magenta
152
+ on(KAMAL.app_hosts) { execute *KAMAL.registry.login }
153
+
154
+ on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role|
155
+ execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
156
+ puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_new_container(cmd, env: env, detach: detach), strip: !raw), quiet: quiet, raw: raw
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+
163
+ desc "containers", "Show app containers on servers"
164
+ def containers
165
+ quiet = options[:quiet]
166
+ on(KAMAL.app_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_containers), quiet: quiet }
167
+ end
168
+
169
+ desc "stale_containers", "Detect app stale containers"
170
+ option :stop, aliases: "-s", type: :boolean, default: false, desc: "Stop the stale containers found"
171
+ def stale_containers
172
+ quiet = options[:quiet]
173
+ stop = options[:stop]
174
+
175
+ with_lock_if_stopping do
176
+ on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role|
177
+ app = KAMAL.app(role: role, host: host)
178
+ versions = capture_with_info(*app.list_versions, raise_on_non_zero_exit: false).split("\n")
179
+ versions -= [ capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip ]
180
+
181
+ versions.each do |version|
182
+ if stop
183
+ puts_by_host host, "Stopping stale container for role #{role} with version #{version}", quiet: quiet
184
+ execute *app.stop(version: version), raise_on_non_zero_exit: false
185
+ else
186
+ puts_by_host host, "Detected stale container for role #{role} with version #{version} (use `kamal app stale_containers --stop` to stop)", quiet: quiet
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
192
+
193
+ desc "images", "Show app images on servers"
194
+ def images
195
+ quiet = options[:quiet]
196
+ on(KAMAL.app_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_images), quiet: quiet }
197
+ end
198
+
199
+ desc "logs", "Show log lines from app on servers (use --help to show options)"
200
+ option :since, aliases: "-s", desc: "Show lines since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)"
201
+ option :lines, type: :numeric, aliases: "-n", desc: "Number of lines to show from each server"
202
+ option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)"
203
+ option :grep_options, desc: "Additional options supplied to grep"
204
+ option :follow, aliases: "-f", desc: "Follow log on primary server (or specific host set by --hosts)"
205
+ option :skip_timestamps, type: :boolean, aliases: "-T", desc: "Skip appending timestamps to logging output"
206
+ option :container_id, desc: "Docker container ID to fetch logs"
207
+ def logs
208
+ # FIXME: Catch when app containers aren't running
209
+
210
+ grep = options[:grep]
211
+ grep_options = options[:grep_options]
212
+ since = options[:since]
213
+ container_id = options[:container_id]
214
+ timestamps = !options[:skip_timestamps]
215
+ quiet = options[:quiet]
216
+
217
+ if options[:follow]
218
+ lines = options[:lines].presence || ((since || grep) ? nil : 10) # Default to 10 lines if since or grep isn't set
219
+
220
+ run_locally do
221
+ info "Following logs on #{KAMAL.primary_host}..."
222
+
223
+ KAMAL.specific_roles ||= [ KAMAL.primary_role.name ]
224
+ role = KAMAL.roles_on(KAMAL.primary_host).first
225
+
226
+ app = KAMAL.app(role: role, host: host)
227
+ info app.follow_logs(host: KAMAL.primary_host, container_id: container_id, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options)
228
+ exec app.follow_logs(host: KAMAL.primary_host, container_id: container_id, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options)
229
+ end
230
+ else
231
+ lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
232
+
233
+ on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role|
234
+ begin
235
+ 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
236
+ rescue SSHKit::Command::Failed
237
+ puts_by_host host, "Nothing found", quiet: quiet
238
+ end
239
+ end
240
+ end
241
+ end
242
+
243
+ desc "remove", "Remove app containers and images from servers"
244
+ def remove
245
+ modify(lock: true) do
246
+ stop
247
+ remove_containers
248
+ remove_images
249
+ remove_app_directories
250
+ end
251
+ end
252
+
253
+ desc "live", "Set the app to live mode"
254
+ def live
255
+ modify(lock: true) do
256
+ on_roles(KAMAL.roles, hosts: KAMAL.proxy_hosts) do |host, role|
257
+ execute *KAMAL.app(role: role, host: host).live if role.running_proxy?
258
+ end
259
+ end
260
+ end
261
+
262
+ desc "maintenance", "Set the app to maintenance mode"
263
+ option :drain_timeout, type: :numeric, desc: "How long to allow in-flight requests to complete (defaults to drain_timeout from config)"
264
+ option :message, type: :string, desc: "Message to display to clients while stopped"
265
+ def maintenance
266
+ maintenance_options = { drain_timeout: options[:drain_timeout] || KAMAL.config.drain_timeout, message: options[:message] }
267
+
268
+ modify(lock: true) do
269
+ on_roles(KAMAL.roles, hosts: KAMAL.proxy_hosts) do |host, role|
270
+ execute *KAMAL.app(role: role, host: host).maintenance(**maintenance_options) if role.running_proxy?
271
+ end
272
+ end
273
+ end
274
+
275
+ desc "remove_container [VERSION]", "Remove app container with given version from servers", hide: true
276
+ def remove_container(version)
277
+ modify(lock: true) do
278
+ on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role|
279
+ execute *KAMAL.auditor.record("Removed app container with version #{version}", role: role), verbosity: :debug
280
+ execute *KAMAL.app(role: role, host: host).remove_container(version: version)
281
+ end
282
+ end
283
+ end
284
+
285
+ desc "remove_containers", "Remove all app containers from servers", hide: true
286
+ def remove_containers
287
+ modify(lock: true) do
288
+ on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role|
289
+ execute *KAMAL.auditor.record("Removed all app containers", role: role), verbosity: :debug
290
+ execute *KAMAL.app(role: role, host: host).remove_containers
291
+ end
292
+ end
293
+ end
294
+
295
+ desc "remove_images", "Remove all app images from servers", hide: true
296
+ def remove_images
297
+ modify(lock: true) do
298
+ on(hosts_removing_all_roles) do
299
+ execute *KAMAL.auditor.record("Removed all app images"), verbosity: :debug
300
+ execute *KAMAL.app.remove_images
301
+ end
302
+ end
303
+ end
304
+
305
+ desc "remove_app_directories", "Remove the app directories from servers", hide: true
306
+ def remove_app_directories
307
+ modify(lock: true) do
308
+ on(hosts_removing_all_roles) do |host|
309
+ execute *KAMAL.server.remove_app_directory, raise_on_non_zero_exit: false
310
+ execute *KAMAL.auditor.record("Removed #{KAMAL.config.app_directory}"), verbosity: :debug
311
+ execute *KAMAL.app.remove_proxy_app_directory, raise_on_non_zero_exit: false
312
+ end
313
+ end
314
+ end
315
+
316
+ desc "version", "Show app version currently running on servers"
317
+ def version
318
+ quiet = options[:quiet]
319
+ on(KAMAL.app_hosts) do |host|
320
+ role = KAMAL.roles_on(host).first
321
+ puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip, quiet: quiet
322
+ end
323
+ end
324
+
325
+ private
326
+ def hosts_removing_all_roles
327
+ KAMAL.app_hosts.select { |host| KAMAL.roles_on(host).map(&:name).sort == KAMAL.config.host_roles(host.to_s).map(&:name).sort }
328
+ end
329
+
330
+ def using_version(new_version)
331
+ if new_version
332
+ begin
333
+ old_version = KAMAL.config.version
334
+ KAMAL.config.version = new_version
335
+ yield new_version
336
+ ensure
337
+ KAMAL.config.version = old_version
338
+ end
339
+ else
340
+ yield KAMAL.config.version
341
+ end
342
+ end
343
+
344
+ def current_running_version(host: KAMAL.primary_host)
345
+ version = nil
346
+ on(host) do
347
+ role = KAMAL.roles_on(host).first
348
+ version = capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip
349
+ end
350
+ version.presence
351
+ end
352
+
353
+ def version_or_latest
354
+ options[:version] || KAMAL.config.latest_tag
355
+ end
356
+
357
+ def with_lock_if_stopping
358
+ if options[:stop]
359
+ modify(lock: true) { yield }
360
+ else
361
+ yield
362
+ end
363
+ end
364
+
365
+ def host_boot_groups
366
+ KAMAL.config.boot.limit ? KAMAL.app_hosts.each_slice(KAMAL.config.boot.limit).to_a : [ KAMAL.app_hosts ]
367
+ end
368
+ end
@@ -0,0 +1,324 @@
1
+ require "thor"
2
+ require "kamal/sshkit_with_ext"
3
+
4
+ module Kamal::Cli
5
+ class Base < Thor
6
+ include SSHKit::DSL
7
+
8
+ VERBOSITY = { verbose: :debug, quiet: :error }.freeze
9
+ AUTOMATIC_DEPLOY_LOCK_MESSAGE = "Automatic deploy lock"
10
+
11
+ class LockHeldError < StandardError; end
12
+ class LockMissingError < StandardError; end
13
+
14
+ def self.exit_on_failure?() true end
15
+ def self.dynamic_command_class() Kamal::Cli::Alias::Command end
16
+
17
+ class_option :verbose, type: :boolean, aliases: "-v", desc: "Detailed logging"
18
+ class_option :quiet, type: :boolean, aliases: "-q", desc: "Minimal logging"
19
+
20
+ class_option :version, desc: "Run commands against a specific app version"
21
+
22
+ class_option :primary, type: :boolean, aliases: "-p", desc: "Run commands only on primary host instead of all"
23
+ class_option :hosts, aliases: "-h", desc: "Run commands on these hosts instead of all (separate by comma, supports wildcards with *)"
24
+ class_option :roles, aliases: "-r", desc: "Run commands on these roles instead of all (separate by comma, supports wildcards with *)"
25
+
26
+ class_option :config_file, aliases: "-c", default: "config/deploy.yml", desc: "Path to config file"
27
+ class_option :destination, aliases: "-d", desc: "Specify destination to be used for config file (staging -> deploy.staging.yml)"
28
+
29
+ class_option :skip_hooks, aliases: "-H", type: :boolean, default: false, desc: "Don't run hooks"
30
+
31
+ class_option :lock_wait, type: :boolean, default: false, desc: "Wait for the deploy lock if it's already held instead of failing immediately"
32
+ class_option :lock_wait_timeout, type: :numeric, default: 900, desc: "Maximum seconds to wait for the deploy lock when --lock-wait is set"
33
+ class_option :lock_wait_interval, type: :numeric, default: 15, desc: "Seconds between deploy lock polls when --lock-wait is set"
34
+
35
+ def initialize(args = [], local_options = {}, config = {})
36
+ if config[:current_command].is_a?(Kamal::Cli::Alias::Command)
37
+ # When Thor generates a dynamic command, it doesn't attempt to parse the arguments.
38
+ # For our purposes, it means the arguments are passed in args rather than local_options.
39
+ super([], args, config)
40
+ else
41
+ super
42
+ end
43
+
44
+ initialize_commander unless KAMAL.configured?
45
+ end
46
+
47
+ private
48
+ def options_with_subcommand_class_options
49
+ options.merge(@_initializer.last[:class_options] || {})
50
+ end
51
+
52
+ def initialize_commander
53
+ KAMAL.tap do |commander|
54
+ if options[:verbose]
55
+ ENV["VERBOSE"] = "1" # For backtraces via cli/start
56
+ commander.verbosity = VERBOSITY[:verbose]
57
+ end
58
+
59
+ if options[:quiet]
60
+ commander.verbosity = VERBOSITY[:quiet]
61
+ end
62
+
63
+ commander.configure \
64
+ config_file: Pathname.new(File.expand_path(options[:config_file])),
65
+ destination: options[:destination],
66
+ version: options[:version]
67
+
68
+ commander.specific_hosts = options[:hosts]&.split(",")
69
+ commander.specific_roles = options[:roles]&.split(",")
70
+ commander.specific_primary! if options[:primary]
71
+
72
+ commander.lock_wait = options[:lock_wait]
73
+ commander.lock_wait_timeout = options[:lock_wait_timeout]
74
+ commander.lock_wait_interval = options[:lock_wait_interval]
75
+ end
76
+ end
77
+
78
+ def print_runtime
79
+ started_at = Time.now
80
+ yield
81
+ Time.now - started_at
82
+ ensure
83
+ runtime = Time.now - started_at
84
+ puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
85
+ end
86
+
87
+ def modify(lock: false)
88
+ KAMAL.modify(command: command, subcommand: subcommand) do
89
+ lock ? with_lock { yield } : yield
90
+ end
91
+ end
92
+
93
+ def say(message = "", *)
94
+ super unless options[:raw]
95
+ KAMAL.log(message.to_s)
96
+ end
97
+
98
+ # Raw output is written straight to stdout for piping, so silence SSHKit's
99
+ # command echoing that would otherwise corrupt the byte stream.
100
+ def with_raw_output(raw, &block)
101
+ raw ? KAMAL.with_verbosity(:error, &block) : block.call
102
+ end
103
+
104
+ def with_lock
105
+ if KAMAL.holding_lock?
106
+ yield
107
+ else
108
+ acquire_lock
109
+
110
+ begin
111
+ yield
112
+ rescue
113
+ begin
114
+ release_lock
115
+ rescue => e
116
+ say "Error releasing the deploy lock: #{e.message}", :red
117
+ end
118
+ raise
119
+ end
120
+
121
+ release_lock
122
+ end
123
+ end
124
+
125
+ def confirming(question)
126
+ return yield if options[:confirmed]
127
+
128
+ if ask(question, limited_to: %w[ y N ], default: "N") == "y"
129
+ yield
130
+ else
131
+ say "Aborted", :red
132
+ end
133
+ end
134
+
135
+ def acquire_lock
136
+ ensure_run_directory
137
+
138
+ if KAMAL.lock_wait
139
+ acquire_lock_with_wait
140
+ else
141
+ raise_if_locked do
142
+ say "Acquiring the deploy lock...", :magenta
143
+ execute_lock_acquire(AUTOMATIC_DEPLOY_LOCK_MESSAGE)
144
+ end
145
+ end
146
+
147
+ KAMAL.holding_lock = true
148
+ end
149
+
150
+ def acquire_lock_with_wait
151
+ timeout = KAMAL.lock_wait_timeout
152
+ interval = KAMAL.lock_wait_interval
153
+ deadline = Time.now + timeout
154
+ details_shown = false
155
+
156
+ say "Acquiring the deploy lock (waiting up to #{timeout}s)...", :magenta
157
+
158
+ loop do
159
+ execute_lock_acquire(AUTOMATIC_DEPLOY_LOCK_MESSAGE)
160
+ break
161
+ rescue LockHeldError
162
+ unless details_shown
163
+ status = capture_lock_status
164
+
165
+ say "Deploy lock is held by:", :magenta
166
+ puts status
167
+
168
+ unless status.include?(AUTOMATIC_DEPLOY_LOCK_MESSAGE)
169
+ raise LockError, "Deploy lock held manually, not waiting. Run 'kamal lock help' for more information"
170
+ end
171
+
172
+ details_shown = true
173
+ end
174
+
175
+ remaining = (deadline - Time.now).to_i
176
+ if remaining <= 0
177
+ say "Timed out after #{timeout}s waiting for the deploy lock", :red
178
+ raise LockError, "Timed out waiting for deploy lock"
179
+ end
180
+
181
+ say "Retrying in #{interval}s (#{remaining}s remaining)...", :magenta
182
+ sleep [ interval, remaining ].min
183
+ end
184
+ end
185
+
186
+ def release_lock
187
+ say "Releasing the deploy lock...", :magenta
188
+ execute_lock_release
189
+
190
+ KAMAL.holding_lock = false
191
+ end
192
+
193
+ def raise_if_locked
194
+ yield
195
+ rescue LockHeldError
196
+ say "Deploy lock already in place!", :red
197
+ puts capture_lock_status
198
+ raise LockError, "Deploy lock found. Run 'kamal lock help' for more information"
199
+ end
200
+
201
+ def execute_lock_acquire(message)
202
+ on(KAMAL.primary_host) { execute *KAMAL.lock.acquire(message, KAMAL.config.version), verbosity: :debug }
203
+ rescue SSHKit::Runner::ExecuteError => e
204
+ raise LockHeldError if e.message =~ /cannot create directory/
205
+ raise
206
+ end
207
+
208
+ def execute_lock_release
209
+ on(KAMAL.primary_host) { execute *KAMAL.lock.release, verbosity: :debug }
210
+ rescue SSHKit::Runner::ExecuteError => e
211
+ raise LockMissingError if e.message =~ /No such file or directory/
212
+ raise
213
+ end
214
+
215
+ def capture_lock_status
216
+ status = nil
217
+ on(KAMAL.primary_host) { status = capture_with_debug(*KAMAL.lock.status) }
218
+ status
219
+ rescue SSHKit::Runner::ExecuteError => e
220
+ raise LockMissingError if e.message =~ /No such file or directory/
221
+ raise
222
+ end
223
+
224
+ def run_hook(hook, **extra_details)
225
+ if !options[:skip_hooks] && KAMAL.hook.hook_exists?(hook)
226
+ details = {
227
+ hosts: KAMAL.hosts.join(","),
228
+ roles: KAMAL.specific_roles&.join(","),
229
+ lock: KAMAL.holding_lock?.to_s,
230
+ command: command,
231
+ subcommand: subcommand
232
+ }.compact
233
+
234
+ hooks_output = KAMAL.config.hooks_output_for(hook)
235
+
236
+ # CLI flags override config: -q hides all, -v shows all
237
+ # Config setting :verbose forces output, :quiet forces silence
238
+ hook_verbosity = if KAMAL.verbosity == :info && hooks_output
239
+ VERBOSITY.fetch(hooks_output)
240
+ else
241
+ KAMAL.verbosity
242
+ end
243
+
244
+ with_env KAMAL.hook.env(**details, **extra_details) do
245
+ KAMAL.with_verbosity(hook_verbosity) do
246
+ run_locally do
247
+ execute *KAMAL.hook.run(hook)
248
+ end
249
+ end
250
+ rescue SSHKit::Command::Failed => e
251
+ raise HookError.new("Hook `#{hook}` failed:\n#{e.message}")
252
+ end
253
+ end
254
+ end
255
+
256
+ def on(*args, &block)
257
+ pre_connect_if_required
258
+
259
+ super
260
+ end
261
+
262
+ def pre_connect_if_required
263
+ if !KAMAL.connected?
264
+ run_hook "pre-connect", secrets: true unless options[:skip_hooks]
265
+ KAMAL.connected = true
266
+ end
267
+ end
268
+
269
+ def command
270
+ @kamal_command ||= begin
271
+ invocation_class, invocation_commands = *first_invocation
272
+ if invocation_class == Kamal::Cli::Main
273
+ invocation_commands[0]
274
+ else
275
+ Kamal::Cli::Main.subcommand_classes.find { |command, clazz| clazz == invocation_class }[0]
276
+ end
277
+ end
278
+ end
279
+
280
+ def subcommand
281
+ @kamal_subcommand ||= begin
282
+ invocation_class, invocation_commands = *first_invocation
283
+ invocation_commands[0] if invocation_class != Kamal::Cli::Main
284
+ end
285
+ end
286
+
287
+ def first_invocation
288
+ instance_variable_get("@_invocations").first
289
+ end
290
+
291
+ def reset_invocation(cli_class)
292
+ instance_variable_get("@_invocations")[cli_class].pop
293
+ end
294
+
295
+ def ensure_run_directory
296
+ on(KAMAL.hosts) do
297
+ execute(*KAMAL.server.ensure_run_directory)
298
+ end
299
+ end
300
+
301
+ def with_env(env)
302
+ current_env = ENV.to_h.dup
303
+ ENV.update(env)
304
+ yield
305
+ ensure
306
+ ENV.clear
307
+ ENV.update(current_env)
308
+ end
309
+
310
+ def ensure_docker_installed
311
+ run_locally do
312
+ begin
313
+ execute *KAMAL.builder.ensure_docker_installed
314
+ rescue SSHKit::Command::Failed => e
315
+ error = e.message =~ /command not found/ ?
316
+ "Docker is not installed locally" :
317
+ "Docker buildx plugin is not installed locally"
318
+
319
+ raise DependencyError, error
320
+ end
321
+ end
322
+ end
323
+ end
324
+ end