dash 4.0.8 → 4.1.1
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/dash/build/progress_parser.rb +136 -0
- data/lib/dash/build/report.rb +104 -0
- data/lib/dash/build/step.rb +49 -0
- data/lib/dash/cli/app/boot.rb +46 -24
- data/lib/dash/cli/app.rb +4 -4
- data/lib/dash/cli/base.rb +117 -2
- data/lib/dash/cli/build.rb +83 -20
- data/lib/dash/cli/doctor/config_checks.rb +36 -1
- data/lib/dash/cli/doctor.rb +2 -1
- data/lib/dash/cli/healthcheck/poller.rb +15 -3
- data/lib/dash/cli/healthcheck/progress_reporter.rb +39 -0
- data/lib/dash/cli/main.rb +24 -7
- data/lib/dash/cli/proxy/drift.rb +17 -2
- data/lib/dash/cli/proxy/legacy_rename.rb +8 -21
- data/lib/dash/cli/proxy/loadbalancer_reboot.rb +8 -1
- data/lib/dash/cli/proxy/reboot.rb +6 -1
- data/lib/dash/cli/proxy.rb +19 -12
- data/lib/dash/cli/prune.rb +5 -8
- data/lib/dash/cli/report.rb +97 -0
- data/lib/dash/cli/templates/sample_hooks/post-deploy.sample +5 -0
- data/lib/dash/commander.rb +10 -2
- data/lib/dash/commands/app.rb +81 -0
- data/lib/dash/commands/auditor.rb +10 -0
- data/lib/dash/commands/base.rb +42 -1
- data/lib/dash/commands/builder/base.rb +18 -0
- data/lib/dash/commands/builder.rb +1 -1
- data/lib/dash/commands/loadbalancer.rb +54 -0
- data/lib/dash/commands/proxy/state.rb +31 -0
- data/lib/dash/commands/proxy.rb +74 -2
- data/lib/dash/commands/registry.rb +14 -0
- data/lib/dash/configuration/docs/configuration.yml +6 -0
- data/lib/dash/configuration/docs/report.yml +39 -0
- data/lib/dash/configuration/docs/role.yml +7 -7
- data/lib/dash/configuration/proxy.rb +3 -0
- data/lib/dash/configuration/report.rb +65 -0
- data/lib/dash/configuration.rb +2 -1
- data/lib/dash/dockerfile/analyzer.rb +66 -0
- data/lib/dash/dockerfile/context.rb +147 -0
- data/lib/dash/dockerfile/dockerignore.rb +29 -0
- data/lib/dash/dockerfile/document.rb +28 -0
- data/lib/dash/dockerfile/finding.rb +20 -0
- data/lib/dash/dockerfile/hadolint.rb +75 -0
- data/lib/dash/dockerfile/instruction.rb +58 -0
- data/lib/dash/dockerfile/parser.rb +199 -0
- data/lib/dash/dockerfile/rules/apt_hygiene.rb +35 -0
- data/lib/dash/dockerfile/rules/base.rb +44 -0
- data/lib/dash/dockerfile/rules/cache_busting_arg.rb +40 -0
- data/lib/dash/dockerfile/rules/cache_export_cost.rb +20 -0
- data/lib/dash/dockerfile/rules/context_size.rb +20 -0
- data/lib/dash/dockerfile/rules/copy_before_install.rb +34 -0
- data/lib/dash/dockerfile/rules/curl_pipe_shell.rb +16 -0
- data/lib/dash/dockerfile/rules/dockerignore_gaps.rb +36 -0
- data/lib/dash/dockerfile/rules/inline_env_blob.rb +23 -0
- data/lib/dash/dockerfile/rules/latest_base.rb +24 -0
- data/lib/dash/dockerfile/rules/missing_dockerignore.rb +11 -0
- data/lib/dash/dockerfile/rules/no_cache_mount.rb +26 -0
- data/lib/dash/dockerfile/rules/root_user.rb +12 -0
- data/lib/dash/dockerfile/rules/secret_in_build_arg.rb +30 -0
- data/lib/dash/dockerfile/rules/single_stage_build_deps.rb +19 -0
- data/lib/dash/dockerfile/rules/uncached_install.rb +20 -0
- data/lib/dash/dockerfile/stage.rb +65 -0
- data/lib/dash/otel_shipper.rb +5 -4
- data/lib/dash/output/otel_logger.rb +52 -0
- data/lib/dash/report/history.rb +94 -0
- data/lib/dash/report/trends.rb +129 -0
- data/lib/dash/report/writer.rb +142 -0
- data/lib/dash/report.rb +170 -0
- data/lib/dash/sshkit_with_ext.rb +62 -0
- data/lib/dash/timings.rb +163 -10
- data/lib/dash/utils.rb +7 -0
- data/lib/dash/version.rb +1 -1
- data/lib/dash.rb +4 -0
- metadata +38 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
# Reads the JSON reports every deploy leaves under `.dash/reports`.
|
|
4
|
+
#
|
|
5
|
+
# Entirely local and read-only: no lock, no SSH, nothing that can change a server. It is
|
|
6
|
+
# the command to reach for after a deploy has finished and the table has scrolled away,
|
|
7
|
+
# and the one that answers "was it always this slow?".
|
|
8
|
+
class Dash::Cli::Report < Dash::Cli::Base
|
|
9
|
+
default_command :show
|
|
10
|
+
|
|
11
|
+
TREND_HEADINGS = %w[ started version total build boot advice ].freeze
|
|
12
|
+
COLUMNS = " %-20s %-10s %8s %8s %8s %s".freeze
|
|
13
|
+
|
|
14
|
+
desc "show", "Print the last saved deploy report"
|
|
15
|
+
option :last, type: :numeric, banner: "N", desc: "Print a trend table over the last N reports instead"
|
|
16
|
+
def show
|
|
17
|
+
if (last = options[:last])
|
|
18
|
+
return say "--last takes a positive number of reports, got #{last}", :red unless count?(last)
|
|
19
|
+
|
|
20
|
+
print_trend saved.recent(last.to_i)
|
|
21
|
+
else
|
|
22
|
+
print_latest saved.recent(1).first
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
desc "path", "Print the directory saved reports are written to"
|
|
27
|
+
def path
|
|
28
|
+
puts reports_directory
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
# Thor's :numeric happily hands over -1 or 2.5, which Array#first turns into a
|
|
33
|
+
# backtrace. A typo in a flag deserves a sentence, not a stack trace.
|
|
34
|
+
def count?(value)
|
|
35
|
+
value.to_i == value && value.to_i > 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def saved
|
|
39
|
+
Dash::Report::History.new(reports_directory, destination: DASH.config.destination)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def print_latest(document)
|
|
43
|
+
return say_nothing_saved unless document
|
|
44
|
+
|
|
45
|
+
say "Deploy report for #{subject}", :magenta
|
|
46
|
+
puts summary_line(document)
|
|
47
|
+
puts Dash::Report.from_h(document).lines
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def print_trend(documents)
|
|
51
|
+
return say_nothing_saved if documents.empty?
|
|
52
|
+
|
|
53
|
+
say "Last #{documents.size} #{"report".pluralize(documents.size)} for #{subject}", :magenta
|
|
54
|
+
puts format(COLUMNS, *TREND_HEADINGS)
|
|
55
|
+
documents.reverse_each { |document| puts trend_row(document) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def subject
|
|
59
|
+
[ DASH.config.service, ("to #{DASH.config.destination}" if DASH.config.destination) ].compact.join(" ")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def summary_line(document)
|
|
63
|
+
" #{document[:command]} #{document[:status]} in #{seconds(document[:runtime])} at #{document[:started_at]}" \
|
|
64
|
+
"#{" (version #{document[:version]})" if document[:version]}#{error_note(document)}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def error_note(document)
|
|
68
|
+
" — #{document.dig(:error, :class)}: #{document.dig(:error, :message)}" if document[:error]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def trend_row(document)
|
|
72
|
+
format COLUMNS, document[:started_at], (document[:version] || "").to_s[0...10],
|
|
73
|
+
seconds(document[:runtime]), phase(document, Dash::Report::Trends::BUILD_PHASE),
|
|
74
|
+
phase(document, Dash::Report::Trends::BOOT_PHASE), advice_count(document)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def phase(document, name)
|
|
78
|
+
found = Array(document[:phases]).find { |candidate| candidate[:name] == name && candidate[:depth].to_i.zero? }
|
|
79
|
+
|
|
80
|
+
found ? seconds(found[:seconds]) : "-"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def advice_count(document)
|
|
84
|
+
findings = Array(document[:advice])
|
|
85
|
+
warnings = findings.count { |finding| finding[:severity] == "warn" }
|
|
86
|
+
|
|
87
|
+
"#{findings.size}#{" (#{warnings} warn)" if warnings > 0}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def seconds(value)
|
|
91
|
+
format("%.1fs", value.to_f)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def say_nothing_saved
|
|
95
|
+
say "No saved reports for #{subject} in #{reports_directory}", :yellow
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -10,5 +10,10 @@
|
|
|
10
10
|
# DASH_ROLES (if set)
|
|
11
11
|
# DASH_DESTINATION (if set)
|
|
12
12
|
# DASH_RUNTIME
|
|
13
|
+
# DASH_BUILD_RUNTIME (if the deploy built an image)
|
|
14
|
+
# DASH_BOOT_RUNTIME (if the deploy booted containers)
|
|
15
|
+
# DASH_ADVICE_COUNT
|
|
16
|
+
# DASH_ADVICE_WARNINGS
|
|
17
|
+
# DASH_REPORT_PATH (unless report/history is 0)
|
|
13
18
|
|
|
14
19
|
echo "$DASH_PERFORMER deployed $DASH_VERSION to $DASH_DESTINATION in $DASH_RUNTIME seconds"
|
data/lib/dash/commander.rb
CHANGED
|
@@ -6,7 +6,13 @@ require "active_support/notifications"
|
|
|
6
6
|
|
|
7
7
|
class Dash::Commander
|
|
8
8
|
attr_accessor :verbosity, :holding_lock, :holding_server_lock, :connected, :logging, :lock_wait, :lock_wait_timeout, :lock_wait_interval
|
|
9
|
-
attr_reader :specific_roles, :specific_hosts, :timings
|
|
9
|
+
attr_reader :specific_roles, :specific_hosts, :timings, :report
|
|
10
|
+
|
|
11
|
+
# Hosts whose run directory this process has already swept, so the second lock acquire
|
|
12
|
+
# of a command does not re-run the migration everywhere. Per host rather than a flag:
|
|
13
|
+
# `dash upgrade` narrows the host set between acquires, and a host that was never in
|
|
14
|
+
# scope has never been swept.
|
|
15
|
+
attr_reader :run_directory_ensured_on
|
|
10
16
|
delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :app_hosts, :proxy_hosts, :accessory_hosts, to: :specifics
|
|
11
17
|
|
|
12
18
|
def initialize
|
|
@@ -24,10 +30,12 @@ class Dash::Commander
|
|
|
24
30
|
self.lock_wait_interval = 15
|
|
25
31
|
@modify_depth = 0
|
|
26
32
|
@timings = Dash::Timings.new
|
|
33
|
+
@report = Dash::Report.new(timings: @timings)
|
|
27
34
|
@specifics = @specific_roles = @specific_hosts = nil
|
|
28
35
|
@config = @config_kwargs = nil
|
|
29
36
|
@output_logger = nil
|
|
30
37
|
@commands = {}
|
|
38
|
+
@run_directory_ensured_on = []
|
|
31
39
|
end
|
|
32
40
|
|
|
33
41
|
def config
|
|
@@ -170,7 +178,7 @@ class Dash::Commander
|
|
|
170
178
|
@logging = true
|
|
171
179
|
if modify_started
|
|
172
180
|
ActiveSupport::Notifications.instrument("modify.kamal",
|
|
173
|
-
command: command, subcommand: subcommand, destination: config.destination, hosts: hosts) { yield }
|
|
181
|
+
command: command, subcommand: subcommand, destination: config.destination, hosts: hosts, report: report) { yield }
|
|
174
182
|
else
|
|
175
183
|
yield
|
|
176
184
|
end
|
data/lib/dash/commands/app.rb
CHANGED
|
@@ -3,6 +3,16 @@ class Dash::Commands::App < Dash::Commands::Base
|
|
|
3
3
|
|
|
4
4
|
ACTIVE_DOCKER_STATUSES = [ :running, :restarting ]
|
|
5
5
|
|
|
6
|
+
# Separates the two answers #boot_state and #stale_state return. A container id is hex
|
|
7
|
+
# and a version is a name suffix, so neither can produce this line on its own.
|
|
8
|
+
BOOT_STATE_SEPARATOR = "--%--"
|
|
9
|
+
|
|
10
|
+
# The two halves of a #boot_state or #stale_state capture, raw. Callers decide what an
|
|
11
|
+
# empty half means; the separator line itself is dropped.
|
|
12
|
+
def self.split_state(output)
|
|
13
|
+
output.to_s.partition(/^#{Regexp.escape(BOOT_STATE_SEPARATOR)}$/).values_at(0, 2)
|
|
14
|
+
end
|
|
15
|
+
|
|
6
16
|
attr_reader :role, :host
|
|
7
17
|
|
|
8
18
|
delegate :container_name, to: :role
|
|
@@ -50,6 +60,31 @@ class Dash::Commands::App < Dash::Commands::Base
|
|
|
50
60
|
docker :exec, container_name(version), *shell([ role.healthcheck.exec ])
|
|
51
61
|
end
|
|
52
62
|
|
|
63
|
+
# Waits on the host for the container to reach a status the poller accepts, so a boot
|
|
64
|
+
# pays one round trip for the wait however long the container takes to come up - the
|
|
65
|
+
# client-side poll paid one per attempt. Prints the status it stopped on to stdout: the
|
|
66
|
+
# moment it sees one of READY_STATUSES, or the last one it saw when the deadline passes.
|
|
67
|
+
# Progress goes to stderr once a second in between. Waiting through every other status is
|
|
68
|
+
# deliberate: docker reports a container `unhealthy` after three failed probes, which for
|
|
69
|
+
# an app slower than that is a state it recovers from.
|
|
70
|
+
#
|
|
71
|
+
# Reaching the deadline exits 0, because it is an answer - the poller phrases it. Only a
|
|
72
|
+
# status that could not be read at all exits non-zero, which is a broken command and
|
|
73
|
+
# SSHKit's to raise, exactly as it was when the read was a round trip of its own.
|
|
74
|
+
def wait_for_ready(version:, timeout:)
|
|
75
|
+
shell [
|
|
76
|
+
"started=$(date +%s);",
|
|
77
|
+
"while true; do",
|
|
78
|
+
*readiness_probe(version: version),
|
|
79
|
+
"case \"$status\" in #{READY_STATUSES.join("|")}) echo \"$status\"; exit 0;; esac;",
|
|
80
|
+
"elapsed=$(( $(date +%s) - started ));",
|
|
81
|
+
"if [ \"$elapsed\" -ge #{timeout.to_i} ]; then echo \"$status\"; exit 0; fi;",
|
|
82
|
+
"echo \"#{READINESS_PROGRESS_PREFIX} $elapsed $(( #{timeout.to_i} - elapsed )) $status\" 1>&2;",
|
|
83
|
+
"sleep 1;",
|
|
84
|
+
"done"
|
|
85
|
+
]
|
|
86
|
+
end
|
|
87
|
+
|
|
53
88
|
def stop(version: nil)
|
|
54
89
|
pipe \
|
|
55
90
|
version ? container_id_for_version(version) : current_running_container_id,
|
|
@@ -75,6 +110,30 @@ class Dash::Commands::App < Dash::Commands::Base
|
|
|
75
110
|
extract_version_from_name
|
|
76
111
|
end
|
|
77
112
|
|
|
113
|
+
# Everything a boot needs to know about a host before it starts anything: whether a
|
|
114
|
+
# container for the version being deployed already exists (so it can be renamed out of
|
|
115
|
+
# the way) and which version is running now (so it can be stopped once the new one is
|
|
116
|
+
# live). Two questions, one round trip, answers split on BOOT_STATE_SEPARATOR.
|
|
117
|
+
#
|
|
118
|
+
# Chained with `;` rather than `&&`: an empty answer to either is a normal result, not
|
|
119
|
+
# a failure, and the second question must be asked whatever the first one said.
|
|
120
|
+
def boot_state(version)
|
|
121
|
+
chain \
|
|
122
|
+
container_id_for_version(version),
|
|
123
|
+
[ :echo, BOOT_STATE_SEPARATOR ],
|
|
124
|
+
current_running_version
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Everything the stale check needs from a host: every version of the role that has a
|
|
128
|
+
# container, and the version running now - the difference is what is stale. Same shape
|
|
129
|
+
# as #boot_state, same separator, same reason for `;` over `&&`.
|
|
130
|
+
def stale_state
|
|
131
|
+
chain \
|
|
132
|
+
list_versions,
|
|
133
|
+
[ :echo, BOOT_STATE_SEPARATOR ],
|
|
134
|
+
current_running_version
|
|
135
|
+
end
|
|
136
|
+
|
|
78
137
|
def list_versions(*docker_args, statuses: nil)
|
|
79
138
|
pipe \
|
|
80
139
|
docker(:ps, *container_filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'),
|
|
@@ -86,6 +145,28 @@ class Dash::Commands::App < Dash::Commands::Base
|
|
|
86
145
|
end
|
|
87
146
|
|
|
88
147
|
private
|
|
148
|
+
# The same two readiness sources #status and #health_probe cover, read into `$status`
|
|
149
|
+
# so the loop around them is the same either way. They differ in what a non-zero exit
|
|
150
|
+
# means. A probe that exits non-zero IS the answer "not ready", so its output is
|
|
151
|
+
# discarded and the loop goes on; an inspect that produced no answer at all - docker is
|
|
152
|
+
# unreachable, or the container is gone - takes the whole command down with it, with
|
|
153
|
+
# docker's complaint on stderr for SSHKit to put in the exception.
|
|
154
|
+
#
|
|
155
|
+
# An empty status is checked as well as the exit code, because the exit code alone is
|
|
156
|
+
# not portable: the read is a pipeline, so its status is xargs', and a `docker container
|
|
157
|
+
# ls` that failed pipes nothing. GNU xargs then runs `docker inspect` with no container
|
|
158
|
+
# and exits 123, but BSD and BusyBox xargs skip the utility entirely and exit 0. Both
|
|
159
|
+
# leave `$status` empty, and empty is not something a working `docker inspect --format`
|
|
160
|
+
# can print.
|
|
161
|
+
def readiness_probe(version:)
|
|
162
|
+
if role.healthcheck&.exec?
|
|
163
|
+
[ "if", *health_probe(version: version), ">/dev/null 2>&1;", "then status=healthy;", "else status=\"#{EXEC_PROBE_FAILED}\";", "fi;" ]
|
|
164
|
+
else
|
|
165
|
+
[ "status=#{substitute(*status(version: version))} || exit $?;",
|
|
166
|
+
"if [ -z \"$status\" ]; then echo \"could not read the status of #{container_name(version)}\" 1>&2; exit 1; fi;" ]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
89
170
|
def latest_image_id
|
|
90
171
|
docker :image, :ls, *argumentize("--filter", "reference=#{config.latest_image}"), "--format", "'{{.ID}}'"
|
|
91
172
|
end
|
|
@@ -14,6 +14,16 @@ class Dash::Commands::Auditor < Dash::Commands::Base
|
|
|
14
14
|
append([ :echo, escape_shell_value(audit_line(line, **details)) ], audit_log_file)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# The audit line and the action it describes in one round trip, still in that order:
|
|
18
|
+
# the log is written first, and `&&` means a failed write aborts the action exactly as
|
|
19
|
+
# a failed standalone audit would have.
|
|
20
|
+
#
|
|
21
|
+
# Only ever fold in commands the caller would `execute`. A `capture` folded in here
|
|
22
|
+
# would come back with nothing to distinguish the audit's own output from the answer.
|
|
23
|
+
def record_then(line, *commands, **details)
|
|
24
|
+
combine record(line, **details), *commands
|
|
25
|
+
end
|
|
26
|
+
|
|
17
27
|
def reveal
|
|
18
28
|
[ :tail, "-n", 50, audit_log_file ]
|
|
19
29
|
end
|
data/lib/dash/commands/base.rb
CHANGED
|
@@ -10,6 +10,22 @@ module Dash::Commands
|
|
|
10
10
|
|
|
11
11
|
DOCKER_HEALTH_STATUS_FORMAT = "'{{if .State.Health}}{{.State.Health.Status}}{{else}}#{NO_HEALTHCHECK}:{{.State.Status}}{{end}}'"
|
|
12
12
|
|
|
13
|
+
# The statuses a boot accepts as ready. Dash::Cli::Healthcheck::Poller decides what a
|
|
14
|
+
# status means; Dash::Commands::App#wait_for_ready only decides when to stop looking,
|
|
15
|
+
# and it stops on exactly these. The two must agree: a status the host loop returned
|
|
16
|
+
# early for that the poller would not accept fails a boot the old client-side poll
|
|
17
|
+
# would have waited out.
|
|
18
|
+
READY_STATUSES = [ "healthy", "#{NO_HEALTHCHECK}:running" ].freeze
|
|
19
|
+
|
|
20
|
+
# What a `healthcheck: exec:` probe reports when it exits non-zero. Produced by the
|
|
21
|
+
# host-side wait, read back by the poller, so it is a wire format, not a message.
|
|
22
|
+
EXEC_PROBE_FAILED = "exec probe exited non-zero"
|
|
23
|
+
|
|
24
|
+
# The line #wait_for_ready prints to stderr on every attempt, read back by
|
|
25
|
+
# Dash::Cli::Healthcheck::ProgressReporter. stderr, because a capture returns stdout
|
|
26
|
+
# alone - which keeps the captured value the final status and nothing else.
|
|
27
|
+
READINESS_PROGRESS_PREFIX = "dash-readiness"
|
|
28
|
+
|
|
13
29
|
attr_accessor :config
|
|
14
30
|
|
|
15
31
|
def initialize(config)
|
|
@@ -24,6 +40,18 @@ module Dash::Commands
|
|
|
24
40
|
docker :container, :ls, *("--all" unless only_running), "--filter", "'name=^#{container_name}$'", "--quiet"
|
|
25
41
|
end
|
|
26
42
|
|
|
43
|
+
# True only when `list_command`'s own output is confirmed empty - never inferred from
|
|
44
|
+
# a failure. `docker container inspect name > /dev/null 2>&1` (negated) cannot tell
|
|
45
|
+
# "no such container" from "the daemon could not be asked" - both exit non-zero - so a
|
|
46
|
+
# transient failure there reads as confirmed absence. A `list` exits 0 whichever way
|
|
47
|
+
# the match went and non-zero only on a genuine failure, so `result=$(list) && [ -z
|
|
48
|
+
# "$result" ]` fails closed: `result=$(list)` carries list's own exit status (POSIX;
|
|
49
|
+
# verified against sh and bash), so a failed list stops the chain before the test runs.
|
|
50
|
+
# `list_command` must be a listing (docker container/volume ls), never an inspect.
|
|
51
|
+
def confirmed_empty?(list_command)
|
|
52
|
+
[ "result=$(#{list_command.join(" ")})", "&&", "[", "-z", "\"$result\"", "]" ]
|
|
53
|
+
end
|
|
54
|
+
|
|
27
55
|
def make_directory_for(remote_file)
|
|
28
56
|
make_directory Pathname.new(remote_file).dirname.to_s
|
|
29
57
|
end
|
|
@@ -98,6 +126,13 @@ module Dash::Commands
|
|
|
98
126
|
combine *commands, by: ";"
|
|
99
127
|
end
|
|
100
128
|
|
|
129
|
+
# One subshell around an && chain. Composing two builders that each mix && and ||
|
|
130
|
+
# cannot be done flat - the operators share precedence and associate left, so the
|
|
131
|
+
# second builder's guards re-associate across the first one's.
|
|
132
|
+
def group(*commands)
|
|
133
|
+
[ "(", *combine(*commands), ")" ]
|
|
134
|
+
end
|
|
135
|
+
|
|
101
136
|
def pipe(*commands)
|
|
102
137
|
combine *commands, by: "|"
|
|
103
138
|
end
|
|
@@ -134,13 +169,19 @@ module Dash::Commands
|
|
|
134
169
|
any \
|
|
135
170
|
volume_exists(volume),
|
|
136
171
|
negate(volume_exists(legacy)),
|
|
137
|
-
|
|
172
|
+
group(docker(:volume, :create, volume), copy_between_volumes(legacy, volume, image: image))
|
|
138
173
|
end
|
|
139
174
|
|
|
140
175
|
def negate(command)
|
|
141
176
|
[ "!", *command ]
|
|
142
177
|
end
|
|
143
178
|
|
|
179
|
+
# The docker builders (network create, the stage-3c network bridge) for callers that
|
|
180
|
+
# compose them into a command of their own rather than executing them on their own.
|
|
181
|
+
def docker_commands
|
|
182
|
+
@docker_commands ||= Dash::Commands::Docker.new(config)
|
|
183
|
+
end
|
|
184
|
+
|
|
144
185
|
def volume_exists(name)
|
|
145
186
|
docker :volume, :inspect, name, ">", "/dev/null", "2>&1"
|
|
146
187
|
end
|
|
@@ -14,9 +14,27 @@ class Dash::Commands::Builder::Base < Dash::Commands::Base
|
|
|
14
14
|
docker :image, :rm, "--force", config.absolute_image
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# Dropping the old image is housekeeping - a host that never had it is not an error -
|
|
18
|
+
# so it must not short-circuit whatever it shares a round trip with.
|
|
19
|
+
#
|
|
20
|
+
# The `|| true` is parenthesised because `&&` and `||` bind equally and associate left:
|
|
21
|
+
# ungrouped, an `audit && clean || true && pull` chain lets a FAILED audit fall into the
|
|
22
|
+
# same `|| true` and pull anyway, exit status 0. The group confines it to the clean.
|
|
23
|
+
#
|
|
24
|
+
# Composed only, never executed on its own: SSHKit's command map prefixes an unknown
|
|
25
|
+
# first word with /usr/bin/env, and the first word here is `(`.
|
|
26
|
+
def clean_then_pull
|
|
27
|
+
combine [ "(", *any(clean, [ :true ]), ")" ], pull
|
|
28
|
+
end
|
|
29
|
+
|
|
17
30
|
def push(export_action = "registry", tag_as_dirty: false, no_cache: false)
|
|
18
31
|
docker :buildx, :build,
|
|
19
32
|
"--output=type=#{export_action}",
|
|
33
|
+
# Plain progress is what dash parses into the build rows of the deploy report
|
|
34
|
+
# (Dash::Build::ProgressParser). buildx already falls back to it when stdout is a
|
|
35
|
+
# pipe, which it always is under SSHKit — this only pins it so the format cannot
|
|
36
|
+
# change under us.
|
|
37
|
+
"--progress=plain",
|
|
20
38
|
*platform_options(arches),
|
|
21
39
|
*([ "--builder", builder_name ] unless docker_driver?),
|
|
22
40
|
*build_tag_options(tag_as_dirty: tag_as_dirty),
|
|
@@ -2,7 +2,7 @@ require "active_support/core_ext/string/filters"
|
|
|
2
2
|
|
|
3
3
|
class Dash::Commands::Builder < Dash::Commands::Base
|
|
4
4
|
delegate \
|
|
5
|
-
:create, :remove, :dev, :push, :clean, :pull, :info, :inspect_builder,
|
|
5
|
+
:create, :remove, :dev, :push, :clean, :pull, :clean_then_pull, :info, :inspect_builder,
|
|
6
6
|
:validate_image, :first_mirror, :login_to_registry_locally?, :push_env,
|
|
7
7
|
to: :target
|
|
8
8
|
|
|
@@ -47,6 +47,28 @@ class Dash::Commands::Loadbalancer < Dash::Commands::Base
|
|
|
47
47
|
copy_legacy_volume(legacy: legacy_config_volume_name, volume: config_volume_name, image: loadbalancer_config.run.image)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# Everything this host needs before anything reads its container, volume or network,
|
|
51
|
+
# in the one round trip it already pays for the apps-config directory. Same shape as
|
|
52
|
+
# Dash::Commands::Proxy#prepare_boot, including why the guard has to be the first word.
|
|
53
|
+
def prepare_boot
|
|
54
|
+
combine legacy_rename, ensure_apps_config_directory
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The loadbalancer's half of the stage-3c bridge - network, then volume - skipped
|
|
58
|
+
# outright by a host that has already been through it. No legacy container is replaced
|
|
59
|
+
# here (a dedicated loadbalancer host never ran one under a name this gem knows), so
|
|
60
|
+
# the marker is verified on the volume instead: the new one exists, or there was never
|
|
61
|
+
# a legacy one to adopt. Stage 3d deletes this with the rest of the bridge.
|
|
62
|
+
def legacy_rename
|
|
63
|
+
any \
|
|
64
|
+
[ :test, "-f", legacy_rename_marker ],
|
|
65
|
+
group(
|
|
66
|
+
group(docker_commands.connect_legacy_network_containers),
|
|
67
|
+
group(copy_legacy_config_volume),
|
|
68
|
+
group(any(mark_legacy_renamed, [ :true ]))
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
50
72
|
def deploy(targets: [])
|
|
51
73
|
docker :exec, container_name, "dash-proxy", "deploy", loadbalancer_config.config.service,
|
|
52
74
|
*loadbalancer_config.deploy_command_args(targets: targets)
|
|
@@ -76,6 +98,12 @@ class Dash::Commands::Loadbalancer < Dash::Commands::Base
|
|
|
76
98
|
docker :inspect, container_name, "--format", Dash::Commands::Proxy::CONFIG_DIGEST_FORMAT
|
|
77
99
|
end
|
|
78
100
|
|
|
101
|
+
# One read for container id, image tag and config digest - parsed by
|
|
102
|
+
# Dash::Commands::Proxy::State, same as the per-host proxy's.
|
|
103
|
+
def inspect_state
|
|
104
|
+
docker :inspect, container_name, "--format", Dash::Commands::Proxy::STATE_FORMAT
|
|
105
|
+
end
|
|
106
|
+
|
|
79
107
|
def container_id(only_running: false)
|
|
80
108
|
container_id_for(container_name: container_name, only_running: only_running)
|
|
81
109
|
end
|
|
@@ -163,6 +191,32 @@ class Dash::Commands::Loadbalancer < Dash::Commands::Base
|
|
|
163
191
|
end
|
|
164
192
|
|
|
165
193
|
private
|
|
194
|
+
# Stage 3c. 3d deletes both of these with the rest of the bridge.
|
|
195
|
+
def legacy_rename_marker
|
|
196
|
+
File.join loadbalancer_config.directory, Dash::Configuration::Proxy::LEGACY_RENAME_MARKER
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Verified on the volume existing, not on a container being gone - the loadbalancer
|
|
200
|
+
# replaces no legacy container, so this is the only signal its bridge has. That makes
|
|
201
|
+
# it foolable in one specific way: if `dash-loadbalancer-config` comes to exist before
|
|
202
|
+
# this bridge ever runs on a host, the marker is written despite the legacy volume's
|
|
203
|
+
# routing table and ACME cache never having been copied. The fix is upstream of the
|
|
204
|
+
# heuristic rather than in it - every path that can create the container, and with it
|
|
205
|
+
# the volume `docker run --volume` auto-creates empty, runs the bridge first:
|
|
206
|
+
# `boot`, both reboots (Dash::Cli::Proxy::Reboot, Dash::Cli::Proxy::LoadbalancerReboot)
|
|
207
|
+
# and `dash proxy loadbalancer start` (zoolutions/dash#168).
|
|
208
|
+
#
|
|
209
|
+
# If a host is somehow in that state anyway - an operator's own `docker run`, a
|
|
210
|
+
# volume created by hand - recovery is to copy the legacy volume's contents over,
|
|
211
|
+
# then remove .legacy-renamed under this host's loadbalancer directory so this
|
|
212
|
+
# re-evaluates.
|
|
213
|
+
def mark_legacy_renamed
|
|
214
|
+
combine \
|
|
215
|
+
group(any(volume_exists(config_volume_name), negate(volume_exists(legacy_config_volume_name)))),
|
|
216
|
+
make_directory(loadbalancer_config.directory),
|
|
217
|
+
[ :touch, legacy_rename_marker ]
|
|
218
|
+
end
|
|
219
|
+
|
|
166
220
|
def run_args
|
|
167
221
|
loadbalancer_config.run_args
|
|
168
222
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# What one `docker inspect` of a proxy container tells a boot: whether it exists, which
|
|
2
|
+
# image tag it runs, and the config digest it was booted with. Three questions that used
|
|
3
|
+
# to cost three round trips each (`container_id`, `version`, `config_digest`).
|
|
4
|
+
#
|
|
5
|
+
# Produced by Dash::Commands::Proxy#inspect_state and its loadbalancer twin, both of which
|
|
6
|
+
# are captured with raise_on_non_zero_exit: false - a host with no container inspects to
|
|
7
|
+
# empty output, which parses to a state that simply does not exist.
|
|
8
|
+
class Dash::Commands::Proxy::State
|
|
9
|
+
attr_reader :id, :image, :digest
|
|
10
|
+
|
|
11
|
+
def self.parse(output)
|
|
12
|
+
id, image, digest = output.to_s.strip.split(" ", 3)
|
|
13
|
+
new(id: id, image: image, digest: digest)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(id: nil, image: nil, digest: nil)
|
|
17
|
+
@id = id.presence
|
|
18
|
+
@image = image.presence
|
|
19
|
+
@digest = digest.presence
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def exists?
|
|
23
|
+
id.present?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The tag, read the way Dash::Commands::Proxy#version reads it - everything past the
|
|
27
|
+
# LAST colon, so a registry host carrying a port does not get mistaken for the version.
|
|
28
|
+
def version
|
|
29
|
+
image&.split(":")&.last
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/dash/commands/proxy.rb
CHANGED
|
@@ -12,8 +12,13 @@ class Dash::Commands::Proxy < Dash::Commands::Base
|
|
|
12
12
|
# Both the legacy constant and the fallback go away in stage 3d.
|
|
13
13
|
LEGACY_CONFIG_DIGEST_LABEL = "org.kamal.proxy-config-digest"
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
"{{ else }}{{ index .Config.Labels \"#{LEGACY_CONFIG_DIGEST_LABEL}\" }}{{ end }}
|
|
15
|
+
CONFIG_DIGEST_TEMPLATE = "{{ with index .Config.Labels \"#{CONFIG_DIGEST_LABEL}\" }}{{ . }}" \
|
|
16
|
+
"{{ else }}{{ index .Config.Labels \"#{LEGACY_CONFIG_DIGEST_LABEL}\" }}{{ end }}"
|
|
17
|
+
|
|
18
|
+
CONFIG_DIGEST_FORMAT = "'#{CONFIG_DIGEST_TEMPLATE}'"
|
|
19
|
+
|
|
20
|
+
# Everything Dash::Cli::Proxy::Drift and the minimum-version gate need, in one format.
|
|
21
|
+
STATE_FORMAT = "'{{.Id}} {{.Config.Image}} #{CONFIG_DIGEST_TEMPLATE}'"
|
|
17
22
|
|
|
18
23
|
def initialize(config, host:)
|
|
19
24
|
super(config)
|
|
@@ -42,6 +47,41 @@ class Dash::Commands::Proxy < Dash::Commands::Base
|
|
|
42
47
|
# destination not existing, so a second deploy is a no-op. Stage 3d deletes
|
|
43
48
|
# them along with the legacy constants they read.
|
|
44
49
|
|
|
50
|
+
# Everything a proxy host needs before anything reads its container, volume or
|
|
51
|
+
# network, in the one round trip it already pays for the apps-config directory.
|
|
52
|
+
#
|
|
53
|
+
# `a || b && c` is `(a || b) && c`, so the mkdir runs whichever way the guard went -
|
|
54
|
+
# and the guard has to be the first word rather than a parenthesised group, because
|
|
55
|
+
# SSHKit prefixes the first word with /usr/bin/env and passes only `test` through.
|
|
56
|
+
# Stage 3d drops the legacy_rename half and leaves the mkdir.
|
|
57
|
+
def prepare_boot
|
|
58
|
+
combine legacy_rename, ensure_apps_config_directory
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The whole stage-3c bridge as one command, skipped outright by a host that has
|
|
62
|
+
# already been through it - or was installed fresh on 4.x and never had a kamal-proxy.
|
|
63
|
+
# The three steps keep their own bodies and their documented order (see
|
|
64
|
+
# Dash::Cli::Proxy::LegacyRename); each is wrapped in its own subshell because they
|
|
65
|
+
# all mix && and || at one precedence level, and composing them flat would
|
|
66
|
+
# re-associate across the volume copy's guard - the chain 4.0.0 got wrong.
|
|
67
|
+
#
|
|
68
|
+
# The marker is written on verified absence of both legacy containers, never on the
|
|
69
|
+
# chain's exit status: the two removals end in `|| true`, so a host whose stop failed
|
|
70
|
+
# would otherwise record itself as migrated and never retry. Its own `|| true` keeps
|
|
71
|
+
# that failure as quiet as it is today, while a failed volume copy still exits
|
|
72
|
+
# non-zero through the && chain and aborts the boot exactly as it does now.
|
|
73
|
+
def legacy_rename
|
|
74
|
+
any \
|
|
75
|
+
[ :test, "-f", legacy_rename_marker ],
|
|
76
|
+
group(
|
|
77
|
+
group(docker_commands.connect_legacy_network_containers),
|
|
78
|
+
group(copy_legacy_config_volume),
|
|
79
|
+
group(remove_legacy_container(timeout: config.drain_timeout)),
|
|
80
|
+
group(remove_legacy_holder_container),
|
|
81
|
+
group(any(mark_legacy_renamed, [ :true ]))
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
45
85
|
# Copies the pre-rename config volume into the new one, before anything
|
|
46
86
|
# starts. The volume holds the routing table and the ACME account and
|
|
47
87
|
# certificate cache; losing it means re-issuing every certificate and
|
|
@@ -61,6 +101,16 @@ class Dash::Commands::Proxy < Dash::Commands::Base
|
|
|
61
101
|
# first deploy. The subshell groups create-and-copy because `&&` and `||`
|
|
62
102
|
# share precedence and associate left — without it a host that already has
|
|
63
103
|
# the new volume would still run the copy over live state.
|
|
104
|
+
#
|
|
105
|
+
# The source volume is still mounted by the legacy container while this runs -
|
|
106
|
+
# deliberately, on every path (see Dash::Cli::Proxy::LegacyRename's step order), and
|
|
107
|
+
# safe because every writer into it renames into place: the routing table through
|
|
108
|
+
# writeFileAtomic, the dynamic domain and redirect state through their own temp +
|
|
109
|
+
# rename, the response cache through CreateTemp + Rename, and the ACME cache through
|
|
110
|
+
# autocert.DirCache. `cp -a` reads a complete file either way. Skew across files is
|
|
111
|
+
# possible and harmless - an unused certificate, or a route whose certificate reissues -
|
|
112
|
+
# and --recheck-targets-on-restore re-verifies the targets on the way back up
|
|
113
|
+
# (zoolutions/dash#169 review).
|
|
64
114
|
def copy_legacy_config_volume(volume: Dash::Configuration::Proxy::CONFIG_VOLUME, legacy: Dash::Configuration::Proxy::LEGACY_CONFIG_VOLUME)
|
|
65
115
|
copy_legacy_volume(legacy: legacy, volume: volume, image: proxy_image)
|
|
66
116
|
end
|
|
@@ -116,6 +166,13 @@ class Dash::Commands::Proxy < Dash::Commands::Base
|
|
|
116
166
|
docker :inspect, container_name, "--format", CONFIG_DIGEST_FORMAT
|
|
117
167
|
end
|
|
118
168
|
|
|
169
|
+
# One read for container id, image tag and config digest - parsed by
|
|
170
|
+
# Dash::Commands::Proxy::State. Capture it with raise_on_non_zero_exit: false;
|
|
171
|
+
# a host with no proxy container inspects to nothing, which is an answer.
|
|
172
|
+
def inspect_state
|
|
173
|
+
docker :inspect, container_name, "--format", STATE_FORMAT
|
|
174
|
+
end
|
|
175
|
+
|
|
119
176
|
def container_id(only_running: false)
|
|
120
177
|
container_id_for(container_name: container_name, only_running: only_running)
|
|
121
178
|
end
|
|
@@ -327,6 +384,21 @@ class Dash::Commands::Proxy < Dash::Commands::Base
|
|
|
327
384
|
docker :container, :inspect, name, ">", "/dev/null", "2>&1"
|
|
328
385
|
end
|
|
329
386
|
|
|
387
|
+
# Stage 3c. 3d deletes both of these with the rest of the bridge.
|
|
388
|
+
def legacy_rename_marker
|
|
389
|
+
File.join config.proxy_boot.host_directory, Dash::Configuration::Proxy::LEGACY_RENAME_MARKER
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# confirmed_empty?, not a negated inspect: a docker error while checking must never
|
|
393
|
+
# read as "confirmed gone" (zoolutions/dash#167 review).
|
|
394
|
+
def mark_legacy_renamed
|
|
395
|
+
combine \
|
|
396
|
+
confirmed_empty?(container_id_for(container_name: Dash::Configuration::Proxy::LEGACY_CONTAINER_NAME)),
|
|
397
|
+
confirmed_empty?(container_id_for(container_name: Dash::Configuration::Proxy::LEGACY_HOLDER_CONTAINER_NAME)),
|
|
398
|
+
make_directory(config.proxy_boot.host_directory),
|
|
399
|
+
[ :touch, legacy_rename_marker ]
|
|
400
|
+
end
|
|
401
|
+
|
|
330
402
|
# The image the volume copy borrows. The proxy this gem is pinned to is
|
|
331
403
|
# already pulled by the time the copy runs, and `rake release` gates on
|
|
332
404
|
# MINIMUM_VERSION being published, so this is always resolvable — unlike
|
|
@@ -13,6 +13,20 @@ class Dash::Commands::Registry < Dash::Commands::Base
|
|
|
13
13
|
"-p", sensitive(Dash::Utils.escape_shell_value(registry_config.password))
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# The login and whatever has to happen after it on the same host, in one round trip.
|
|
17
|
+
# `docker login` prints "Login Succeeded" to stdout, so its output is redirected away:
|
|
18
|
+
# a caller that captures this gets the folded command's answer and nothing else. A local
|
|
19
|
+
# registry needs no login at all, and the fold collapses to the commands alone.
|
|
20
|
+
#
|
|
21
|
+
# The credentials stay wrapped in sensitive(...) - composing keeps the array elements
|
|
22
|
+
# intact, so SSHKit redacts them here exactly as it does for a standalone login.
|
|
23
|
+
def login_then(*commands, registry_config: nil)
|
|
24
|
+
login = login(registry_config: registry_config)
|
|
25
|
+
login = [ *login, ">", "/dev/null" ] if login
|
|
26
|
+
|
|
27
|
+
combine login, *commands
|
|
28
|
+
end
|
|
29
|
+
|
|
16
30
|
def logout(registry_config: nil)
|
|
17
31
|
registry_config ||= config.registry
|
|
18
32
|
|