kamal 1.3.1 → 1.4.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 +4 -4
- data/lib/kamal/cli/accessory.rb +35 -27
- data/lib/kamal/cli/app.rb +9 -10
- data/lib/kamal/cli/base.rb +2 -1
- data/lib/kamal/cli/env.rb +1 -3
- data/lib/kamal/cli/main.rb +6 -3
- data/lib/kamal/cli/prune.rb +6 -2
- data/lib/kamal/cli/server.rb +3 -1
- data/lib/kamal/cli/templates/deploy.yml +4 -0
- data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +7 -0
- data/lib/kamal/cli/traefik.rb +2 -2
- data/lib/kamal/commander.rb +2 -2
- data/lib/kamal/commands/app/assets.rb +8 -8
- data/lib/kamal/commands/app/cord.rb +3 -3
- data/lib/kamal/commands/app/execution.rb +2 -2
- data/lib/kamal/commands/app/logging.rb +2 -2
- data/lib/kamal/commands/app.rb +12 -13
- data/lib/kamal/commands/base.rb +8 -0
- data/lib/kamal/commands/builder/base.rb +10 -3
- data/lib/kamal/commands/builder/multiarch.rb +9 -1
- data/lib/kamal/commands/docker.rb +10 -1
- data/lib/kamal/commands/prune.rb +2 -2
- data/lib/kamal/commands/registry.rb +4 -1
- data/lib/kamal/commands/traefik.rb +2 -2
- data/lib/kamal/configuration/accessory.rb +1 -1
- data/lib/kamal/configuration/boot.rb +1 -1
- data/lib/kamal/configuration/builder.rb +4 -0
- data/lib/kamal/configuration/role.rb +15 -2
- data/lib/kamal/configuration.rb +38 -18
- data/lib/kamal/sshkit_with_ext.rb +1 -0
- data/lib/kamal/version.rb +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: df82a171edf966865ca45112056965b93b50a700c4ae190cebe791992c41a825
|
4
|
+
data.tar.gz: 9c5290566c477597460910a80a6e0694285e6d9779b6897bc6c1c10f5bd23b7b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c2995cb335465be583037f0a23e2a5d3ce128b47e162b69d7666dbc3e313b542c1d10abef9fa2bb542bdb679003dc8993c3037c4738a9e8544fb346755e8ced0
|
7
|
+
data.tar.gz: a0b7d5500f7ad5408409e0f655274e19a833a8158d2013e79a70e7209d87f5c350b715071d8981f9119f9b3d8c66033bfcd23b5290d3d3880511b3c98c7cff83
|
data/lib/kamal/cli/accessory.rb
CHANGED
@@ -5,11 +5,11 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
5
5
|
if name == "all"
|
6
6
|
KAMAL.accessory_names.each { |accessory_name| boot(accessory_name) }
|
7
7
|
else
|
8
|
-
with_accessory(name) do |accessory|
|
8
|
+
with_accessory(name) do |accessory, hosts|
|
9
9
|
directories(name)
|
10
10
|
upload(name)
|
11
11
|
|
12
|
-
on(
|
12
|
+
on(hosts) do
|
13
13
|
execute *KAMAL.registry.login if login
|
14
14
|
execute *KAMAL.auditor.record("Booted #{name} accessory"), verbosity: :debug
|
15
15
|
execute *accessory.run
|
@@ -22,8 +22,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
22
22
|
desc "upload [NAME]", "Upload accessory files to host", hide: true
|
23
23
|
def upload(name)
|
24
24
|
mutating do
|
25
|
-
with_accessory(name) do |accessory|
|
26
|
-
on(
|
25
|
+
with_accessory(name) do |accessory, hosts|
|
26
|
+
on(hosts) do
|
27
27
|
accessory.files.each do |(local, remote)|
|
28
28
|
accessory.ensure_local_file_present(local)
|
29
29
|
|
@@ -39,8 +39,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
39
39
|
desc "directories [NAME]", "Create accessory directories on host", hide: true
|
40
40
|
def directories(name)
|
41
41
|
mutating do
|
42
|
-
with_accessory(name) do |accessory|
|
43
|
-
on(
|
42
|
+
with_accessory(name) do |accessory, hosts|
|
43
|
+
on(hosts) do
|
44
44
|
accessory.directories.keys.each do |host_path|
|
45
45
|
execute *accessory.make_directory(host_path)
|
46
46
|
end
|
@@ -55,8 +55,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
55
55
|
if name == "all"
|
56
56
|
KAMAL.accessory_names.each { |accessory_name| reboot(accessory_name) }
|
57
57
|
else
|
58
|
-
with_accessory(name) do |accessory|
|
59
|
-
on(
|
58
|
+
with_accessory(name) do |accessory, hosts|
|
59
|
+
on(hosts) do
|
60
60
|
execute *KAMAL.registry.login
|
61
61
|
end
|
62
62
|
|
@@ -71,8 +71,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
71
71
|
desc "start [NAME]", "Start existing accessory container on host"
|
72
72
|
def start(name)
|
73
73
|
mutating do
|
74
|
-
with_accessory(name) do |accessory|
|
75
|
-
on(
|
74
|
+
with_accessory(name) do |accessory, hosts|
|
75
|
+
on(hosts) do
|
76
76
|
execute *KAMAL.auditor.record("Started #{name} accessory"), verbosity: :debug
|
77
77
|
execute *accessory.start
|
78
78
|
end
|
@@ -83,8 +83,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
83
83
|
desc "stop [NAME]", "Stop existing accessory container on host"
|
84
84
|
def stop(name)
|
85
85
|
mutating do
|
86
|
-
with_accessory(name) do |accessory|
|
87
|
-
on(
|
86
|
+
with_accessory(name) do |accessory, hosts|
|
87
|
+
on(hosts) do
|
88
88
|
execute *KAMAL.auditor.record("Stopped #{name} accessory"), verbosity: :debug
|
89
89
|
execute *accessory.stop, raise_on_non_zero_exit: false
|
90
90
|
end
|
@@ -107,8 +107,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
107
107
|
if name == "all"
|
108
108
|
KAMAL.accessory_names.each { |accessory_name| details(accessory_name) }
|
109
109
|
else
|
110
|
-
with_accessory(name) do |accessory|
|
111
|
-
on(
|
110
|
+
with_accessory(name) do |accessory, hosts|
|
111
|
+
on(hosts) { puts capture_with_info(*accessory.info) }
|
112
112
|
end
|
113
113
|
end
|
114
114
|
end
|
@@ -117,7 +117,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
117
117
|
option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
|
118
118
|
option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
|
119
119
|
def exec(name, cmd)
|
120
|
-
with_accessory(name) do |accessory|
|
120
|
+
with_accessory(name) do |accessory, hosts|
|
121
121
|
case
|
122
122
|
when options[:interactive] && options[:reuse]
|
123
123
|
say "Launching interactive command with via SSH from existing container...", :magenta
|
@@ -129,14 +129,14 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
129
129
|
|
130
130
|
when options[:reuse]
|
131
131
|
say "Launching command from existing container...", :magenta
|
132
|
-
on(
|
132
|
+
on(hosts) do
|
133
133
|
execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
|
134
134
|
capture_with_info(*accessory.execute_in_existing_container(cmd))
|
135
135
|
end
|
136
136
|
|
137
137
|
else
|
138
138
|
say "Launching command from new container...", :magenta
|
139
|
-
on(
|
139
|
+
on(hosts) do
|
140
140
|
execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
|
141
141
|
capture_with_info(*accessory.execute_in_new_container(cmd))
|
142
142
|
end
|
@@ -150,12 +150,12 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
150
150
|
option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)"
|
151
151
|
option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)"
|
152
152
|
def logs(name)
|
153
|
-
with_accessory(name) do |accessory|
|
153
|
+
with_accessory(name) do |accessory, hosts|
|
154
154
|
grep = options[:grep]
|
155
155
|
|
156
156
|
if options[:follow]
|
157
157
|
run_locally do
|
158
|
-
info "Following logs on #{
|
158
|
+
info "Following logs on #{hosts}..."
|
159
159
|
info accessory.follow_logs(grep: grep)
|
160
160
|
exec accessory.follow_logs(grep: grep)
|
161
161
|
end
|
@@ -163,7 +163,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
163
163
|
since = options[:since]
|
164
164
|
lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
|
165
165
|
|
166
|
-
on(
|
166
|
+
on(hosts) do
|
167
167
|
puts capture_with_info(*accessory.logs(since: since, lines: lines, grep: grep))
|
168
168
|
end
|
169
169
|
end
|
@@ -192,8 +192,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
192
192
|
desc "remove_container [NAME]", "Remove accessory container from host", hide: true
|
193
193
|
def remove_container(name)
|
194
194
|
mutating do
|
195
|
-
with_accessory(name) do |accessory|
|
196
|
-
on(
|
195
|
+
with_accessory(name) do |accessory, hosts|
|
196
|
+
on(hosts) do
|
197
197
|
execute *KAMAL.auditor.record("Remove #{name} accessory container"), verbosity: :debug
|
198
198
|
execute *accessory.remove_container
|
199
199
|
end
|
@@ -204,8 +204,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
204
204
|
desc "remove_image [NAME]", "Remove accessory image from host", hide: true
|
205
205
|
def remove_image(name)
|
206
206
|
mutating do
|
207
|
-
with_accessory(name) do |accessory|
|
208
|
-
on(
|
207
|
+
with_accessory(name) do |accessory, hosts|
|
208
|
+
on(hosts) do
|
209
209
|
execute *KAMAL.auditor.record("Removed #{name} accessory image"), verbosity: :debug
|
210
210
|
execute *accessory.remove_image
|
211
211
|
end
|
@@ -216,8 +216,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
216
216
|
desc "remove_service_directory [NAME]", "Remove accessory directory used for uploaded files and data directories from host", hide: true
|
217
217
|
def remove_service_directory(name)
|
218
218
|
mutating do
|
219
|
-
with_accessory(name) do |accessory|
|
220
|
-
on(
|
219
|
+
with_accessory(name) do |accessory, hosts|
|
220
|
+
on(hosts) do
|
221
221
|
execute *accessory.remove_service_directory
|
222
222
|
end
|
223
223
|
end
|
@@ -227,7 +227,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
227
227
|
private
|
228
228
|
def with_accessory(name)
|
229
229
|
if accessory = KAMAL.accessory(name)
|
230
|
-
yield accessory
|
230
|
+
yield accessory, accessory_hosts(accessory)
|
231
231
|
else
|
232
232
|
error_on_missing_accessory(name)
|
233
233
|
end
|
@@ -240,4 +240,12 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
|
240
240
|
"No accessory by the name of '#{name}'" +
|
241
241
|
(options ? " (options: #{options.to_sentence})" : "")
|
242
242
|
end
|
243
|
+
|
244
|
+
def accessory_hosts(accessory)
|
245
|
+
if KAMAL.specific_hosts&.any?
|
246
|
+
KAMAL.specific_hosts & accessory.hosts
|
247
|
+
else
|
248
|
+
accessory.hosts
|
249
|
+
end
|
250
|
+
end
|
243
251
|
end
|
data/lib/kamal/cli/app.rb
CHANGED
@@ -13,9 +13,8 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
|
13
13
|
|
14
14
|
KAMAL.roles_on(host).each do |role|
|
15
15
|
app = KAMAL.app(role: role)
|
16
|
-
role_config = KAMAL.config.role(role)
|
17
16
|
|
18
|
-
if
|
17
|
+
if role.assets?
|
19
18
|
execute *app.extract_assets
|
20
19
|
old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
|
21
20
|
execute *app.sync_asset_volumes(old_version: old_version)
|
@@ -27,7 +26,6 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
|
27
26
|
KAMAL.roles_on(host).each do |role|
|
28
27
|
app = KAMAL.app(role: role)
|
29
28
|
auditor = KAMAL.auditor(role: role)
|
30
|
-
role_config = KAMAL.config.role(role)
|
31
29
|
|
32
30
|
if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present?
|
33
31
|
tmp_version = "#{version}_replaced_#{SecureRandom.hex(8)}"
|
@@ -38,7 +36,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
|
38
36
|
|
39
37
|
old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
|
40
38
|
|
41
|
-
execute *app.tie_cord(
|
39
|
+
execute *app.tie_cord(role.cord_host_file) if role.uses_cord?
|
42
40
|
|
43
41
|
execute *auditor.record("Booted app version #{version}"), verbosity: :debug
|
44
42
|
|
@@ -47,7 +45,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
|
47
45
|
Kamal::Cli::Healthcheck::Poller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) }
|
48
46
|
|
49
47
|
if old_version.present?
|
50
|
-
if
|
48
|
+
if role.uses_cord?
|
51
49
|
cord = capture_with_info(*app.cord(version: old_version), raise_on_non_zero_exit: false).strip
|
52
50
|
if cord.present?
|
53
51
|
execute *app.cut_cord(cord)
|
@@ -57,7 +55,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
|
57
55
|
|
58
56
|
execute *app.stop(version: old_version), raise_on_non_zero_exit: false
|
59
57
|
|
60
|
-
execute *app.clean_up_assets if
|
58
|
+
execute *app.clean_up_assets if role.assets?
|
61
59
|
end
|
62
60
|
end
|
63
61
|
end
|
@@ -202,19 +200,20 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
|
202
200
|
# FIXME: Catch when app containers aren't running
|
203
201
|
|
204
202
|
grep = options[:grep]
|
205
|
-
|
203
|
+
since = options[:since]
|
206
204
|
if options[:follow]
|
205
|
+
lines = options[:lines].presence || ((since || grep) ? nil : 10) # Default to 10 lines if since or grep isn't set
|
206
|
+
|
207
207
|
run_locally do
|
208
208
|
info "Following logs on #{KAMAL.primary_host}..."
|
209
209
|
|
210
210
|
KAMAL.specific_roles ||= ["web"]
|
211
211
|
role = KAMAL.roles_on(KAMAL.primary_host).first
|
212
212
|
|
213
|
-
info KAMAL.app(role: role).follow_logs(host: KAMAL.primary_host, grep: grep)
|
214
|
-
exec KAMAL.app(role: role).follow_logs(host: KAMAL.primary_host, grep: grep)
|
213
|
+
info KAMAL.app(role: role).follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep)
|
214
|
+
exec KAMAL.app(role: role).follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep)
|
215
215
|
end
|
216
216
|
else
|
217
|
-
since = options[:since]
|
218
217
|
lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
|
219
218
|
|
220
219
|
on(KAMAL.hosts) do |host|
|
data/lib/kamal/cli/base.rb
CHANGED
@@ -123,8 +123,9 @@ module Kamal::Cli
|
|
123
123
|
yield
|
124
124
|
rescue SSHKit::Runner::ExecuteError => e
|
125
125
|
if e.message =~ /cannot create directory/
|
126
|
+
say "Deploy lock already in place!", :red
|
126
127
|
on(KAMAL.primary_host) { puts capture_with_debug(*KAMAL.lock.status) }
|
127
|
-
raise LockError, "Deploy lock found"
|
128
|
+
raise LockError, "Deploy lock found. Run 'kamal lock help' for more information"
|
128
129
|
else
|
129
130
|
raise e
|
130
131
|
end
|
data/lib/kamal/cli/env.rb
CHANGED
@@ -8,9 +8,8 @@ class Kamal::Cli::Env < Kamal::Cli::Base
|
|
8
8
|
execute *KAMAL.auditor.record("Pushed env files"), verbosity: :debug
|
9
9
|
|
10
10
|
KAMAL.roles_on(host).each do |role|
|
11
|
-
role_config = KAMAL.config.role(role)
|
12
11
|
execute *KAMAL.app(role: role).make_env_directory
|
13
|
-
upload! StringIO.new(
|
12
|
+
upload! StringIO.new(role.env_file), role.host_env_file_path, mode: 400
|
14
13
|
end
|
15
14
|
end
|
16
15
|
|
@@ -36,7 +35,6 @@ class Kamal::Cli::Env < Kamal::Cli::Base
|
|
36
35
|
execute *KAMAL.auditor.record("Deleted env files"), verbosity: :debug
|
37
36
|
|
38
37
|
KAMAL.roles_on(host).each do |role|
|
39
|
-
role_config = KAMAL.config.role(role)
|
40
38
|
execute *KAMAL.app(role: role).remove_env_file
|
41
39
|
end
|
42
40
|
end
|
data/lib/kamal/cli/main.rb
CHANGED
@@ -1,15 +1,18 @@
|
|
1
1
|
class Kamal::Cli::Main < Kamal::Cli::Base
|
2
2
|
desc "setup", "Setup all accessories, push the env, and deploy app to servers"
|
3
|
+
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
|
3
4
|
def setup
|
4
5
|
print_runtime do
|
5
6
|
mutating do
|
7
|
+
invoke_options = deploy_options
|
8
|
+
|
6
9
|
say "Ensure Docker is installed...", :magenta
|
7
|
-
invoke "kamal:cli:server:bootstrap"
|
10
|
+
invoke "kamal:cli:server:bootstrap", [], invoke_options
|
8
11
|
|
9
12
|
say "Push env files...", :magenta
|
10
|
-
invoke "kamal:cli:env:push"
|
13
|
+
invoke "kamal:cli:env:push", [], invoke_options
|
11
14
|
|
12
|
-
invoke "kamal:cli:accessory:boot", [ "all" ]
|
15
|
+
invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options
|
13
16
|
deploy
|
14
17
|
end
|
15
18
|
end
|
data/lib/kamal/cli/prune.rb
CHANGED
@@ -18,12 +18,16 @@ class Kamal::Cli::Prune < Kamal::Cli::Base
|
|
18
18
|
end
|
19
19
|
end
|
20
20
|
|
21
|
-
desc "containers", "Prune all stopped containers, except the last 5"
|
21
|
+
desc "containers", "Prune all stopped containers, except the last n (default 5)"
|
22
|
+
option :retain, type: :numeric, default: nil, desc: "Number of containers to retain"
|
22
23
|
def containers
|
24
|
+
retain = options.fetch(:retain, KAMAL.config.retain_containers)
|
25
|
+
raise "retain must be at least 1" if retain < 1
|
26
|
+
|
23
27
|
mutating do
|
24
28
|
on(KAMAL.hosts) do
|
25
29
|
execute *KAMAL.auditor.record("Pruned containers"), verbosity: :debug
|
26
|
-
execute *KAMAL.prune.app_containers
|
30
|
+
execute *KAMAL.prune.app_containers(retain: retain)
|
27
31
|
execute *KAMAL.prune.healthcheck_containers
|
28
32
|
end
|
29
33
|
end
|
data/lib/kamal/cli/server.rb
CHANGED
@@ -17,7 +17,9 @@ class Kamal::Cli::Server < Kamal::Cli::Base
|
|
17
17
|
end
|
18
18
|
|
19
19
|
if missing.any?
|
20
|
-
raise "Docker is not installed on #{missing.join(", ")} and can't be automatically installed without having root access and
|
20
|
+
raise "Docker is not installed on #{missing.join(", ")} and can't be automatically installed without having root access and either `wget` or `curl`. Install Docker manually: https://docs.docker.com/engine/install/"
|
21
21
|
end
|
22
|
+
|
23
|
+
run_hook "docker-setup"
|
22
24
|
end
|
23
25
|
end
|
@@ -77,6 +77,10 @@ registry:
|
|
77
77
|
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
|
78
78
|
# hitting 404 on in-flight requests. Combines all files from new and old
|
79
79
|
# version inside the asset_path.
|
80
|
+
#
|
81
|
+
# If your app is using the Sprockets gem, ensure it sets `config.assets.manifest`.
|
82
|
+
# See https://github.com/basecamp/kamal/issues/626 for details
|
83
|
+
#
|
80
84
|
# asset_path: /rails/public/assets
|
81
85
|
|
82
86
|
# Configure rolling deploys by setting a wait time between batches of restarts.
|
data/lib/kamal/cli/traefik.rb
CHANGED
@@ -20,7 +20,7 @@ class Kamal::Cli::Traefik < Kamal::Cli::Base
|
|
20
20
|
on(hosts) do
|
21
21
|
execute *KAMAL.auditor.record("Rebooted traefik"), verbosity: :debug
|
22
22
|
execute *KAMAL.registry.login
|
23
|
-
execute *KAMAL.traefik.stop
|
23
|
+
execute *KAMAL.traefik.stop, raise_on_non_zero_exit: false
|
24
24
|
execute *KAMAL.traefik.remove_container
|
25
25
|
execute *KAMAL.traefik.run
|
26
26
|
end
|
@@ -44,7 +44,7 @@ class Kamal::Cli::Traefik < Kamal::Cli::Base
|
|
44
44
|
mutating do
|
45
45
|
on(KAMAL.traefik_hosts) do
|
46
46
|
execute *KAMAL.auditor.record("Stopped traefik"), verbosity: :debug
|
47
|
-
execute *KAMAL.traefik.stop
|
47
|
+
execute *KAMAL.traefik.stop, raise_on_non_zero_exit: false
|
48
48
|
end
|
49
49
|
end
|
50
50
|
end
|
data/lib/kamal/commander.rb
CHANGED
@@ -53,7 +53,7 @@ class Kamal::Commander
|
|
53
53
|
|
54
54
|
def primary_host
|
55
55
|
# Given a list of specific roles, make an effort to match up with the primary_role
|
56
|
-
specific_hosts&.first || specific_roles&.detect { |role| role
|
56
|
+
specific_hosts&.first || specific_roles&.detect { |role| role == config.primary_role }&.primary_host || specific_roles&.first&.primary_host || config.primary_host
|
57
57
|
end
|
58
58
|
|
59
59
|
def primary_role
|
@@ -73,7 +73,7 @@ class Kamal::Commander
|
|
73
73
|
end
|
74
74
|
|
75
75
|
def roles_on(host)
|
76
|
-
roles.select { |role| role.hosts.include?(host.to_s) }
|
76
|
+
roles.select { |role| role.hosts.include?(host.to_s) }
|
77
77
|
end
|
78
78
|
|
79
79
|
def traefik_hosts
|
@@ -1,20 +1,20 @@
|
|
1
1
|
module Kamal::Commands::App::Assets
|
2
2
|
def extract_assets
|
3
|
-
asset_container = "#{
|
3
|
+
asset_container = "#{role.container_prefix}-assets"
|
4
4
|
|
5
5
|
combine \
|
6
|
-
make_directory(
|
6
|
+
make_directory(role.asset_extracted_path),
|
7
7
|
[*docker(:stop, "-t 1", asset_container, "2> /dev/null"), "|| true"],
|
8
8
|
docker(:run, "--name", asset_container, "--detach", "--rm", config.latest_image, "sleep 1000000"),
|
9
|
-
docker(:cp, "-L", "#{asset_container}:#{
|
9
|
+
docker(:cp, "-L", "#{asset_container}:#{role.asset_path}/.", role.asset_extracted_path),
|
10
10
|
docker(:stop, "-t 1", asset_container),
|
11
11
|
by: "&&"
|
12
12
|
end
|
13
13
|
|
14
14
|
def sync_asset_volumes(old_version: nil)
|
15
|
-
new_extracted_path, new_volume_path =
|
15
|
+
new_extracted_path, new_volume_path = role.asset_extracted_path(config.version), role.asset_volume.host_path
|
16
16
|
if old_version.present?
|
17
|
-
old_extracted_path, old_volume_path =
|
17
|
+
old_extracted_path, old_volume_path = role.asset_extracted_path(old_version), role.asset_volume(old_version).host_path
|
18
18
|
end
|
19
19
|
|
20
20
|
commands = [make_directory(new_volume_path), copy_contents(new_extracted_path, new_volume_path)]
|
@@ -29,8 +29,8 @@ module Kamal::Commands::App::Assets
|
|
29
29
|
|
30
30
|
def clean_up_assets
|
31
31
|
chain \
|
32
|
-
find_and_remove_older_siblings(
|
33
|
-
find_and_remove_older_siblings(
|
32
|
+
find_and_remove_older_siblings(role.asset_extracted_path),
|
33
|
+
find_and_remove_older_siblings(role.asset_volume_path)
|
34
34
|
end
|
35
35
|
|
36
36
|
private
|
@@ -39,7 +39,7 @@ module Kamal::Commands::App::Assets
|
|
39
39
|
:find,
|
40
40
|
Pathname.new(path).dirname.to_s,
|
41
41
|
"-maxdepth 1",
|
42
|
-
"-name", "'#{
|
42
|
+
"-name", "'#{role.container_prefix}-*'",
|
43
43
|
"!", "-name", Pathname.new(path).basename.to_s,
|
44
44
|
"-exec rm -rf \"{}\" +"
|
45
45
|
]
|
@@ -2,7 +2,7 @@ module Kamal::Commands::App::Cord
|
|
2
2
|
def cord(version:)
|
3
3
|
pipe \
|
4
4
|
docker(:inspect, "-f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}'", container_name(version)),
|
5
|
-
[:awk, "'$2 == \"#{
|
5
|
+
[:awk, "'$2 == \"#{role.cord_volume.container_path}\" {print $1}'"]
|
6
6
|
end
|
7
7
|
|
8
8
|
def tie_cord(cord)
|
@@ -12,8 +12,8 @@ module Kamal::Commands::App::Cord
|
|
12
12
|
def cut_cord(cord)
|
13
13
|
remove_directory(cord)
|
14
14
|
end
|
15
|
-
|
16
|
-
private
|
15
|
+
|
16
|
+
private
|
17
17
|
def create_empty_file(file)
|
18
18
|
chain \
|
19
19
|
make_directory_for(file),
|
@@ -10,9 +10,9 @@ module Kamal::Commands::App::Execution
|
|
10
10
|
docker :run,
|
11
11
|
("-it" if interactive),
|
12
12
|
"--rm",
|
13
|
-
*
|
13
|
+
*role&.env_args,
|
14
14
|
*config.volume_args,
|
15
|
-
*
|
15
|
+
*role&.option_args,
|
16
16
|
config.absolute_image,
|
17
17
|
*command
|
18
18
|
end
|
@@ -6,11 +6,11 @@ module Kamal::Commands::App::Logging
|
|
6
6
|
("grep '#{grep}'" if grep)
|
7
7
|
end
|
8
8
|
|
9
|
-
def follow_logs(host:, grep: nil)
|
9
|
+
def follow_logs(host:, lines: nil, grep: nil)
|
10
10
|
run_over_ssh \
|
11
11
|
pipe(
|
12
12
|
current_running_container_id,
|
13
|
-
"xargs docker logs --timestamps --tail
|
13
|
+
"xargs docker logs --timestamps#{" --tail #{lines}" if lines} --follow 2>&1",
|
14
14
|
(%(grep "#{grep}") if grep)
|
15
15
|
),
|
16
16
|
host: host
|
data/lib/kamal/commands/app.rb
CHANGED
@@ -3,12 +3,11 @@ class Kamal::Commands::App < Kamal::Commands::Base
|
|
3
3
|
|
4
4
|
ACTIVE_DOCKER_STATUSES = [ :running, :restarting ]
|
5
5
|
|
6
|
-
attr_reader :role, :
|
6
|
+
attr_reader :role, :role
|
7
7
|
|
8
8
|
def initialize(config, role: nil)
|
9
9
|
super(config)
|
10
10
|
@role = role
|
11
|
-
@role_config = config.role(self.role)
|
12
11
|
end
|
13
12
|
|
14
13
|
def run(hostname: nil)
|
@@ -19,15 +18,15 @@ class Kamal::Commands::App < Kamal::Commands::Base
|
|
19
18
|
*(["--hostname", hostname] if hostname),
|
20
19
|
"-e", "KAMAL_CONTAINER_NAME=\"#{container_name}\"",
|
21
20
|
"-e", "KAMAL_VERSION=\"#{config.version}\"",
|
22
|
-
*
|
23
|
-
*
|
24
|
-
*
|
21
|
+
*role.env_args,
|
22
|
+
*role.health_check_args,
|
23
|
+
*role.logging_args,
|
25
24
|
*config.volume_args,
|
26
|
-
*
|
27
|
-
*
|
28
|
-
*
|
25
|
+
*role.asset_volume_args,
|
26
|
+
*role.label_args,
|
27
|
+
*role.option_args,
|
29
28
|
config.absolute_image,
|
30
|
-
|
29
|
+
role.cmd
|
31
30
|
end
|
32
31
|
|
33
32
|
def start
|
@@ -64,22 +63,22 @@ class Kamal::Commands::App < Kamal::Commands::Base
|
|
64
63
|
def list_versions(*docker_args, statuses: nil)
|
65
64
|
pipe \
|
66
65
|
docker(:ps, *filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'),
|
67
|
-
%(while read line; do echo ${line##{
|
66
|
+
%(while read line; do echo ${line##{role.container_prefix}-}; done) # Extract SHA from "service-role-dest-SHA"
|
68
67
|
end
|
69
68
|
|
70
69
|
|
71
70
|
def make_env_directory
|
72
|
-
make_directory
|
71
|
+
make_directory role.host_env_directory
|
73
72
|
end
|
74
73
|
|
75
74
|
def remove_env_file
|
76
|
-
[ :rm, "-f",
|
75
|
+
[ :rm, "-f", role.host_env_file_path ]
|
77
76
|
end
|
78
77
|
|
79
78
|
|
80
79
|
private
|
81
80
|
def container_name(version = nil)
|
82
|
-
[
|
81
|
+
[ role.container_prefix, version || config.version ].compact.join("-")
|
83
82
|
end
|
84
83
|
|
85
84
|
def filter_args(statuses: nil)
|
data/lib/kamal/commands/base.rb
CHANGED
@@ -62,10 +62,18 @@ module Kamal::Commands
|
|
62
62
|
combine *commands, by: ">"
|
63
63
|
end
|
64
64
|
|
65
|
+
def any(*commands)
|
66
|
+
combine *commands, by: "||"
|
67
|
+
end
|
68
|
+
|
65
69
|
def xargs(command)
|
66
70
|
[ :xargs, command ].flatten
|
67
71
|
end
|
68
72
|
|
73
|
+
def shell(command)
|
74
|
+
[ :sh, "-c", "'#{command.flatten.join(" ").gsub("'", "'\\''")}'" ]
|
75
|
+
end
|
76
|
+
|
69
77
|
def docker(*args)
|
70
78
|
args.compact.unshift :docker
|
71
79
|
end
|
@@ -3,7 +3,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
|
|
3
3
|
class BuilderError < StandardError; end
|
4
4
|
|
5
5
|
delegate :argumentize, to: Kamal::Utils
|
6
|
-
delegate :args, :secrets, :dockerfile, :local_arch, :local_host, :remote_arch, :remote_host, :cache_from, :cache_to, to: :builder_config
|
6
|
+
delegate :args, :secrets, :dockerfile, :local_arch, :local_host, :remote_arch, :remote_host, :cache_from, :cache_to, :ssh, to: :builder_config
|
7
7
|
|
8
8
|
def clean
|
9
9
|
docker :image, :rm, "--force", config.absolute_image
|
@@ -14,7 +14,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
|
|
14
14
|
end
|
15
15
|
|
16
16
|
def build_options
|
17
|
-
[ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile ]
|
17
|
+
[ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_ssh ]
|
18
18
|
end
|
19
19
|
|
20
20
|
def build_context
|
@@ -24,7 +24,10 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
|
|
24
24
|
def validate_image
|
25
25
|
pipe \
|
26
26
|
docker(:inspect, "-f", "'{{ .Config.Labels.service }}'", config.absolute_image),
|
27
|
-
|
27
|
+
any(
|
28
|
+
[:grep, "-x", config.service],
|
29
|
+
"(echo \"Image #{config.absolute_image} is missing the 'service' label\" && exit 1)"
|
30
|
+
)
|
28
31
|
end
|
29
32
|
|
30
33
|
|
@@ -60,6 +63,10 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
|
|
60
63
|
end
|
61
64
|
end
|
62
65
|
|
66
|
+
def build_ssh
|
67
|
+
argumentize "--ssh", ssh if ssh.present?
|
68
|
+
end
|
69
|
+
|
63
70
|
def builder_config
|
64
71
|
config.builder
|
65
72
|
end
|
@@ -10,7 +10,7 @@ class Kamal::Commands::Builder::Multiarch < Kamal::Commands::Builder::Base
|
|
10
10
|
def push
|
11
11
|
docker :buildx, :build,
|
12
12
|
"--push",
|
13
|
-
"--platform",
|
13
|
+
"--platform", platform_names,
|
14
14
|
"--builder", builder_name,
|
15
15
|
*build_options,
|
16
16
|
build_context
|
@@ -26,4 +26,12 @@ class Kamal::Commands::Builder::Multiarch < Kamal::Commands::Builder::Base
|
|
26
26
|
def builder_name
|
27
27
|
"kamal-#{config.service}-multiarch"
|
28
28
|
end
|
29
|
+
|
30
|
+
def platform_names
|
31
|
+
if local_arch
|
32
|
+
"linux/#{local_arch}"
|
33
|
+
else
|
34
|
+
"linux/amd64,linux/arm64"
|
35
|
+
end
|
36
|
+
end
|
29
37
|
end
|
@@ -1,7 +1,7 @@
|
|
1
1
|
class Kamal::Commands::Docker < Kamal::Commands::Base
|
2
2
|
# Install Docker using the https://github.com/docker/docker-install convenience script.
|
3
3
|
def install
|
4
|
-
pipe
|
4
|
+
pipe get_docker, :sh
|
5
5
|
end
|
6
6
|
|
7
7
|
# Checks the Docker client version. Fails if Docker is not installed.
|
@@ -18,4 +18,13 @@ class Kamal::Commands::Docker < Kamal::Commands::Base
|
|
18
18
|
def superuser?
|
19
19
|
[ '[ "${EUID:-$(id -u)}" -eq 0 ] || command -v sudo >/dev/null || command -v su >/dev/null' ]
|
20
20
|
end
|
21
|
+
|
22
|
+
private
|
23
|
+
def get_docker
|
24
|
+
shell \
|
25
|
+
any \
|
26
|
+
[ :curl, "-fsSL", "https://get.docker.com" ],
|
27
|
+
[ :wget, "-O -", "https://get.docker.com" ],
|
28
|
+
[ :echo, "\"exit 1\"" ]
|
29
|
+
end
|
21
30
|
end
|
data/lib/kamal/commands/prune.rb
CHANGED
@@ -13,10 +13,10 @@ class Kamal::Commands::Prune < Kamal::Commands::Base
|
|
13
13
|
"while read image tag; do docker rmi $tag; done"
|
14
14
|
end
|
15
15
|
|
16
|
-
def app_containers(
|
16
|
+
def app_containers(retain:)
|
17
17
|
pipe \
|
18
18
|
docker(:ps, "-q", "-a", *service_filter, *stopped_containers_filters),
|
19
|
-
"tail -n +#{
|
19
|
+
"tail -n +#{retain + 1}",
|
20
20
|
"while read container_id; do docker rm $container_id; done"
|
21
21
|
end
|
22
22
|
|
@@ -2,7 +2,10 @@ class Kamal::Commands::Registry < Kamal::Commands::Base
|
|
2
2
|
delegate :registry, to: :config
|
3
3
|
|
4
4
|
def login
|
5
|
-
docker :login,
|
5
|
+
docker :login,
|
6
|
+
registry["server"],
|
7
|
+
"-u", sensitive(Kamal::Utils.escape_shell_value(lookup("username"))),
|
8
|
+
"-p", sensitive(Kamal::Utils.escape_shell_value(lookup("password")))
|
6
9
|
end
|
7
10
|
|
8
11
|
def logout
|
@@ -1,7 +1,7 @@
|
|
1
1
|
class Kamal::Commands::Traefik < Kamal::Commands::Base
|
2
2
|
delegate :argumentize, :optionize, to: Kamal::Utils
|
3
3
|
|
4
|
-
DEFAULT_IMAGE = "traefik:v2.
|
4
|
+
DEFAULT_IMAGE = "traefik:v2.10"
|
5
5
|
CONTAINER_PORT = 80
|
6
6
|
DEFAULT_ARGS = {
|
7
7
|
'log.level' => 'DEBUG'
|
@@ -39,7 +39,7 @@ class Kamal::Commands::Traefik < Kamal::Commands::Base
|
|
39
39
|
end
|
40
40
|
|
41
41
|
def start_or_run
|
42
|
-
|
42
|
+
any start, run
|
43
43
|
end
|
44
44
|
|
45
45
|
def info
|
@@ -3,9 +3,10 @@ class Kamal::Configuration::Role
|
|
3
3
|
delegate :argumentize, :optionize, to: Kamal::Utils
|
4
4
|
|
5
5
|
attr_accessor :name
|
6
|
+
alias to_s name
|
6
7
|
|
7
8
|
def initialize(name, config:)
|
8
|
-
|
9
|
+
@name, @config = name.inquiry, config
|
9
10
|
end
|
10
11
|
|
11
12
|
def primary_host
|
@@ -36,6 +37,18 @@ class Kamal::Configuration::Role
|
|
36
37
|
argumentize "--label", labels
|
37
38
|
end
|
38
39
|
|
40
|
+
def logging_args
|
41
|
+
args = config.logging || {}
|
42
|
+
args.deep_merge!(specializations["logging"]) if specializations["logging"].present?
|
43
|
+
|
44
|
+
if args.any?
|
45
|
+
optionize({ "log-driver" => args["driver"] }.compact) +
|
46
|
+
argumentize("--log-opt", args["options"])
|
47
|
+
else
|
48
|
+
config.logging_args
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
39
52
|
|
40
53
|
def env
|
41
54
|
if config.env && config.env["secret"]
|
@@ -101,7 +114,7 @@ class Kamal::Configuration::Role
|
|
101
114
|
end
|
102
115
|
|
103
116
|
def primary?
|
104
|
-
@config.primary_role
|
117
|
+
self == @config.primary_role
|
105
118
|
end
|
106
119
|
|
107
120
|
|
data/lib/kamal/configuration.rb
CHANGED
@@ -6,7 +6,7 @@ require "erb"
|
|
6
6
|
require "net/ssh/proxy/jump"
|
7
7
|
|
8
8
|
class Kamal::Configuration
|
9
|
-
delegate :service, :image, :servers, :env, :labels, :registry, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true
|
9
|
+
delegate :service, :image, :servers, :env, :labels, :registry, :stop_wait_time, :hooks_path, :logging, to: :raw_config, allow_nil: true
|
10
10
|
delegate :argumentize, :optionize, to: Kamal::Utils
|
11
11
|
|
12
12
|
attr_reader :destination, :raw_config
|
@@ -92,7 +92,19 @@ class Kamal::Configuration
|
|
92
92
|
end
|
93
93
|
|
94
94
|
def primary_host
|
95
|
-
|
95
|
+
primary_role&.primary_host
|
96
|
+
end
|
97
|
+
|
98
|
+
def primary_role_name
|
99
|
+
raw_config.primary_role || "web"
|
100
|
+
end
|
101
|
+
|
102
|
+
def primary_role
|
103
|
+
role(primary_role_name)
|
104
|
+
end
|
105
|
+
|
106
|
+
def allow_empty_roles?
|
107
|
+
raw_config.allow_empty_roles
|
96
108
|
end
|
97
109
|
|
98
110
|
def traefik_roles
|
@@ -127,6 +139,10 @@ class Kamal::Configuration
|
|
127
139
|
raw_config.require_destination
|
128
140
|
end
|
129
141
|
|
142
|
+
def retain_containers
|
143
|
+
raw_config.retain_containers || 5
|
144
|
+
end
|
145
|
+
|
130
146
|
|
131
147
|
def volume_args
|
132
148
|
if raw_config.volumes.present?
|
@@ -137,9 +153,9 @@ class Kamal::Configuration
|
|
137
153
|
end
|
138
154
|
|
139
155
|
def logging_args
|
140
|
-
if
|
141
|
-
optionize({ "log-driver" =>
|
142
|
-
argumentize("--log-opt",
|
156
|
+
if logging.present?
|
157
|
+
optionize({ "log-driver" => logging["driver"] }.compact) +
|
158
|
+
argumentize("--log-opt", logging["options"])
|
143
159
|
else
|
144
160
|
argumentize("--log-opt", { "max-size" => "10m" })
|
145
161
|
end
|
@@ -208,17 +224,9 @@ class Kamal::Configuration
|
|
208
224
|
raw_config.asset_path
|
209
225
|
end
|
210
226
|
|
211
|
-
def primary_role
|
212
|
-
raw_config.primary_role || "web"
|
213
|
-
end
|
214
|
-
|
215
|
-
def allow_empty_roles?
|
216
|
-
raw_config.allow_empty_roles
|
217
|
-
end
|
218
|
-
|
219
227
|
|
220
228
|
def valid?
|
221
|
-
ensure_destination_if_required && ensure_required_keys_present && ensure_valid_kamal_version
|
229
|
+
ensure_destination_if_required && ensure_required_keys_present && ensure_valid_kamal_version && ensure_retain_containers_valid && ensure_valid_service_name
|
222
230
|
end
|
223
231
|
|
224
232
|
def to_h
|
@@ -264,12 +272,12 @@ class Kamal::Configuration
|
|
264
272
|
raise ArgumentError, "You must specify a password for the registry in config/deploy.yml (or set the ENV variable if that's used)"
|
265
273
|
end
|
266
274
|
|
267
|
-
unless role_names.include?(
|
268
|
-
raise ArgumentError, "The primary_role #{
|
275
|
+
unless role_names.include?(primary_role_name)
|
276
|
+
raise ArgumentError, "The primary_role #{primary_role_name} isn't defined"
|
269
277
|
end
|
270
278
|
|
271
|
-
if
|
272
|
-
raise ArgumentError, "No servers specified for the #{primary_role} primary_role"
|
279
|
+
if primary_role.hosts.empty?
|
280
|
+
raise ArgumentError, "No servers specified for the #{primary_role.name} primary_role"
|
273
281
|
end
|
274
282
|
|
275
283
|
unless allow_empty_roles?
|
@@ -283,6 +291,12 @@ class Kamal::Configuration
|
|
283
291
|
true
|
284
292
|
end
|
285
293
|
|
294
|
+
def ensure_valid_service_name
|
295
|
+
raise ArgumentError, "Service name can only include alphanumeric characters, hyphens, and underscores" unless raw_config[:service] =~ /^[a-z0-9_-]+$/
|
296
|
+
|
297
|
+
true
|
298
|
+
end
|
299
|
+
|
286
300
|
def ensure_valid_kamal_version
|
287
301
|
if minimum_version && Gem::Version.new(minimum_version) > Gem::Version.new(Kamal::VERSION)
|
288
302
|
raise ArgumentError, "Current version is #{Kamal::VERSION}, minimum required is #{minimum_version}"
|
@@ -291,6 +305,12 @@ class Kamal::Configuration
|
|
291
305
|
true
|
292
306
|
end
|
293
307
|
|
308
|
+
def ensure_retain_containers_valid
|
309
|
+
raise ArgumentError, "Must retain at least 1 container" if retain_containers < 1
|
310
|
+
|
311
|
+
true
|
312
|
+
end
|
313
|
+
|
294
314
|
|
295
315
|
def role_names
|
296
316
|
raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
|
data/lib/kamal/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: kamal
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- David Heinemeier Hansson
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-03-20 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -217,6 +217,7 @@ files:
|
|
217
217
|
- lib/kamal/cli/registry.rb
|
218
218
|
- lib/kamal/cli/server.rb
|
219
219
|
- lib/kamal/cli/templates/deploy.yml
|
220
|
+
- lib/kamal/cli/templates/sample_hooks/docker-setup.sample
|
220
221
|
- lib/kamal/cli/templates/sample_hooks/post-deploy.sample
|
221
222
|
- lib/kamal/cli/templates/sample_hooks/post-traefik-reboot.sample
|
222
223
|
- lib/kamal/cli/templates/sample_hooks/pre-build.sample
|
@@ -286,7 +287,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
286
287
|
- !ruby/object:Gem::Version
|
287
288
|
version: '0'
|
288
289
|
requirements: []
|
289
|
-
rubygems_version: 3.5.
|
290
|
+
rubygems_version: 3.5.6
|
290
291
|
signing_key:
|
291
292
|
specification_version: 4
|
292
293
|
summary: Deploy web apps in containers to servers running Docker with zero downtime.
|