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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb6c271cec9b9ab118649ffeaf503aa5ba31bd8d0f8057ce0a61adb93e130ef4
|
|
4
|
+
data.tar.gz: ce7f60b8e4a764e06ab868663f825134d8f73cac0e11f663793e6a12471ab490
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 35204403e1680a42ce0b0726225d68c55119a74b387a9cd45189822499e5aa225c250ad82190944cbd4a5ff7aa976b3dcf02600e44b92a8a4ed165f86123c96b
|
|
7
|
+
data.tar.gz: cb520eb7f9028fad75e4537afcdcc1c7aea71aa6cb58c35a33b9c8d185781a4de9ad7a9f3d1f1f8daba8b73390b279bbd292ac1a82d531a303f34086c69c28d3
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Reads `docker buildx build --progress=plain` as it streams past and turns it into a
|
|
2
|
+
# per-step report. It is an SSHKit interaction handler, so it sees the same bytes SSHKit
|
|
3
|
+
# is already printing — no second process, no `docker buildx history`, no extra command.
|
|
4
|
+
#
|
|
5
|
+
# The stream is line-oriented but arrives in chunks (the SSH backend splits on packet
|
|
6
|
+
# boundaries, not newlines), so data is buffered and only whole lines are parsed. Every
|
|
7
|
+
# line belongs to a vertex — `#12` — and the first line for a vertex names it; the rest
|
|
8
|
+
# are events on it (DONE, CACHED, ERROR) or its own stdout, which is ignored.
|
|
9
|
+
#
|
|
10
|
+
# Nothing here may raise into a build. A parse error stops the parsing and is reported
|
|
11
|
+
# once by the caller; the deploy keeps whatever was collected up to that point.
|
|
12
|
+
class Dash::Build::ProgressParser
|
|
13
|
+
VERTEX = /\A#(?<number>\d+)(?: (?<rest>.*))?\z/
|
|
14
|
+
|
|
15
|
+
# `[linux/amd64 build 2/3] RUN …` — the platform prefix only appears on multi-platform
|
|
16
|
+
# builds, and the stage name only when the Dockerfile named it with AS.
|
|
17
|
+
STEP = %r{\A\[(?:(?<platform>[a-z0-9]+/[a-z0-9][\w./-]*) )?(?:(?<stage>[A-Za-z0-9][\w.-]*) )?(?<ordinal>\d+)/(?<steps_in_stage>\d+)\] (?<instruction>.+)\z}
|
|
18
|
+
INTERNAL = /\A\[(?:\S+ )?internal\] (?<what>.+)\z/
|
|
19
|
+
DONE = /\ADONE (?<seconds>\d+(?:\.\d+)?)s\z/
|
|
20
|
+
ERROR = /\AERROR: (?<message>.+)\z/
|
|
21
|
+
TRANSFERRING_CONTEXT = /\Atransferring context: (?<size>[\d.]+)(?<unit>[a-zA-Z]+)(?: (?<seconds>\d+(?:\.\d+)?)s)? done\z/
|
|
22
|
+
PUSHING = /\Apushing .*?(?<seconds>\d+(?:\.\d+)?)s done\z/
|
|
23
|
+
|
|
24
|
+
BYTE_UNITS = { "b" => 1, "kb" => 1_000, "mb" => 1_000_000, "gb" => 1_000_000_000, "tb" => 1_000_000_000_000 }.freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :error
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@steps = {}
|
|
30
|
+
@order = []
|
|
31
|
+
@buffer = +""
|
|
32
|
+
@push_seconds = 0.0
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# SSHKit's interaction-handler contract.
|
|
37
|
+
def on_data(_command, _stream_name, data, _channel)
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
next if @error
|
|
40
|
+
|
|
41
|
+
@buffer << data.to_s
|
|
42
|
+
while (newline = @buffer.index("\n"))
|
|
43
|
+
parse_line @buffer.slice!(0..newline).chomp
|
|
44
|
+
end
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
@error = e
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The last line of a build has no trailing newline when the command dies mid-write.
|
|
51
|
+
def finish
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
next if @error
|
|
54
|
+
|
|
55
|
+
parse_line @buffer.chomp unless @buffer.empty?
|
|
56
|
+
@buffer = +""
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
@error = e
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def result
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
Dash::Build::Report.new(steps: @order.map { |number| @steps[number] }, push_seconds: @push_seconds)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
def parse_line(line)
|
|
70
|
+
match = VERTEX.match(line) or return
|
|
71
|
+
rest = match[:rest].to_s.strip
|
|
72
|
+
|
|
73
|
+
# `#3 ...` is buildx saying the vertex is deferred, not a step of its own.
|
|
74
|
+
return if rest.empty? || rest == "..."
|
|
75
|
+
|
|
76
|
+
number = match[:number].to_i
|
|
77
|
+
step = @steps[number]
|
|
78
|
+
|
|
79
|
+
unless step
|
|
80
|
+
step = @steps[number] = build_step(number, rest)
|
|
81
|
+
@order << number
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
update step, rest
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_step(number, rest)
|
|
88
|
+
Dash::Build::Step.new(number, kind: :other, name: rest).tap do |step|
|
|
89
|
+
if (match = STEP.match(rest))
|
|
90
|
+
step.platform = match[:platform]
|
|
91
|
+
step.stage = match[:stage]
|
|
92
|
+
step.ordinal = match[:ordinal].to_i
|
|
93
|
+
step.steps_in_stage = match[:steps_in_stage].to_i
|
|
94
|
+
step.instruction = match[:instruction].squeeze(" ").strip
|
|
95
|
+
step.kind = step.instruction.start_with?("FROM ") ? :from : :instruction
|
|
96
|
+
elsif (match = INTERNAL.match(rest))
|
|
97
|
+
step.kind = internal_kind(match[:what])
|
|
98
|
+
elsif rest.start_with?("exporting cache")
|
|
99
|
+
step.kind = :cache_export
|
|
100
|
+
elsif rest.match?(/\A(exporting|pushing|writing image)/)
|
|
101
|
+
step.kind = :export
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def internal_kind(what)
|
|
107
|
+
case what
|
|
108
|
+
when /\Aload build context/ then :context
|
|
109
|
+
when /\Aload metadata for/ then :metadata
|
|
110
|
+
else :other
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Later lines for a vertex are its result, its output, or its progress. buildx
|
|
115
|
+
# re-reports DONE per platform on a multi-platform build, so the last one wins.
|
|
116
|
+
def update(step, rest)
|
|
117
|
+
if (match = DONE.match(rest))
|
|
118
|
+
step.seconds = match[:seconds].to_f
|
|
119
|
+
elsif rest == "CACHED"
|
|
120
|
+
step.cached = true
|
|
121
|
+
step.seconds = 0.0
|
|
122
|
+
elsif (match = ERROR.match(rest))
|
|
123
|
+
step.error = match[:message]
|
|
124
|
+
elsif (match = TRANSFERRING_CONTEXT.match(rest)) && step.kind == :context
|
|
125
|
+
step.bytes = bytes(match[:size], match[:unit])
|
|
126
|
+
step.seconds ||= match[:seconds]&.to_f
|
|
127
|
+
elsif (match = PUSHING.match(rest)) && step.kind == :export
|
|
128
|
+
@push_seconds += match[:seconds].to_f
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# buildx reports decimal units — 25.18MB is 25,180,000 bytes, not 25.18 MiB.
|
|
133
|
+
def bytes(size, unit)
|
|
134
|
+
(size.to_f * BYTE_UNITS.fetch(unit.downcase, 1)).round
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# What the build actually spent its time on, derived from the buildx progress stream.
|
|
2
|
+
#
|
|
3
|
+
# "Steps" here means the operator's own Dockerfile steps — the vertices buildx numbered
|
|
4
|
+
# `[stage k/m]`. BuildKit's own bookkeeping (booting, auth tokens, metadata lookups) is
|
|
5
|
+
# kept in `steps` because it is still time the build took, but it is never counted as a
|
|
6
|
+
# step the operator wrote.
|
|
7
|
+
class Dash::Build::Report
|
|
8
|
+
attr_reader :steps, :push_seconds
|
|
9
|
+
|
|
10
|
+
# Rebuilds a report from what #to_h exported. Only the steps and the push are restored:
|
|
11
|
+
# every other number in the export is derived from them, so recomputing keeps a
|
|
12
|
+
# hand-edited file from claiming a total its own steps do not add up to.
|
|
13
|
+
def self.from_h(hash)
|
|
14
|
+
hash = hash.transform_keys(&:to_sym)
|
|
15
|
+
|
|
16
|
+
new steps: Array(hash[:steps]).map { |step| Dash::Build::Step.from_h(step) }, push_seconds: hash[:push_seconds].to_f
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(steps: [], push_seconds: 0.0)
|
|
20
|
+
@steps = steps
|
|
21
|
+
@push_seconds = push_seconds
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def any?
|
|
25
|
+
steps.any?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def dockerfile_steps
|
|
29
|
+
steps.select(&:dockerfile_step?)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def instruction_steps
|
|
33
|
+
steps.select { |step| step.kind == :instruction }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def cached_steps
|
|
37
|
+
dockerfile_steps.select(&:cached)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def uncached_steps
|
|
41
|
+
dockerfile_steps.reject(&:cached)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The rows worth printing: the operator's own instructions, longest first. A FROM or a
|
|
45
|
+
# metadata lookup is not something they can speed up by editing the Dockerfile.
|
|
46
|
+
def slowest(count)
|
|
47
|
+
instruction_steps.reject(&:cached).sort_by { |step| -step.seconds.to_f }.first(count)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def context_step
|
|
51
|
+
steps.find { |step| step.kind == :context }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def context_bytes
|
|
55
|
+
context_step&.bytes
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def context_seconds
|
|
59
|
+
context_step&.seconds
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def export_seconds
|
|
63
|
+
seconds_for(:export)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def cache_export_seconds
|
|
67
|
+
seconds_for(:cache_export)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def total_step_seconds
|
|
71
|
+
steps.sum { |step| step.seconds.to_f }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def errors
|
|
75
|
+
steps.select(&:error)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The errors worth putting in front of an operator. BuildKit reports a cache-import
|
|
79
|
+
# miss as an ERROR on its own vertex — the first build against a fresh cache always
|
|
80
|
+
# has one — and a row saying "error" for something that did not fail the build teaches
|
|
81
|
+
# people to ignore the column.
|
|
82
|
+
def failed_steps
|
|
83
|
+
errors.select { |step| step.dockerfile_step? || step.kind == :export }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_h
|
|
87
|
+
{
|
|
88
|
+
context_bytes: context_bytes,
|
|
89
|
+
context_seconds: context_seconds,
|
|
90
|
+
cached_steps: cached_steps.size,
|
|
91
|
+
total_steps: dockerfile_steps.size,
|
|
92
|
+
export_seconds: export_seconds,
|
|
93
|
+
cache_export_seconds: cache_export_seconds,
|
|
94
|
+
push_seconds: push_seconds,
|
|
95
|
+
total_step_seconds: total_step_seconds,
|
|
96
|
+
steps: steps.map(&:to_h)
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
def seconds_for(kind)
|
|
102
|
+
steps.sum { |step| step.kind == kind ? step.seconds.to_f : 0.0 }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# One buildx vertex. A vertex is a unit of work BuildKit reports on: a Dockerfile
|
|
2
|
+
# instruction, the context transfer, a metadata lookup, the export to the registry.
|
|
3
|
+
#
|
|
4
|
+
# Only the ones with an ordinal (`[build 5/9]`) are the operator's own steps — the rest
|
|
5
|
+
# are BuildKit's own bookkeeping, and counting them would make "cached steps 9 of 22"
|
|
6
|
+
# say something nobody asked.
|
|
7
|
+
class Dash::Build::Step
|
|
8
|
+
attr_reader :number
|
|
9
|
+
attr_accessor :kind, :name, :platform, :stage, :ordinal, :steps_in_stage, :instruction, :seconds, :cached, :error, :bytes
|
|
10
|
+
|
|
11
|
+
def self.from_h(step)
|
|
12
|
+
step = step.transform_keys(&:to_sym)
|
|
13
|
+
|
|
14
|
+
new(step[:number], kind: step[:kind]&.to_sym || :other, name: step[:label]).tap do |rebuilt|
|
|
15
|
+
rebuilt.platform, rebuilt.stage = step[:platform], step[:stage]
|
|
16
|
+
rebuilt.ordinal, rebuilt.steps_in_stage = step[:ordinal], step[:steps_in_stage]
|
|
17
|
+
rebuilt.instruction, rebuilt.seconds = step[:instruction], step[:seconds]
|
|
18
|
+
rebuilt.cached, rebuilt.bytes, rebuilt.error = !!step[:cached], step[:bytes], step[:error]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(number, kind: :other, name: nil)
|
|
23
|
+
@number = number
|
|
24
|
+
@kind = kind
|
|
25
|
+
@name = name
|
|
26
|
+
@cached = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# What buildx itself printed for this vertex, minus the platform prefix it adds on a
|
|
30
|
+
# multi-platform build — the operator matches these against their Dockerfile, and the
|
|
31
|
+
# platform is already its own column in the data.
|
|
32
|
+
def label
|
|
33
|
+
ordinal ? "[#{[ stage, "#{ordinal}/#{steps_in_stage}" ].compact.join(" ")}] #{instruction}" : name.to_s
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def dockerfile_step?
|
|
37
|
+
!ordinal.nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# `label` is here for whatever reads the JSON — it is the string buildx printed and the
|
|
41
|
+
# one a human matches against their Dockerfile — and the parts it is built from are here
|
|
42
|
+
# so #from_h can rebuild it rather than trusting a field a hand-edited file may disagree
|
|
43
|
+
# with.
|
|
44
|
+
def to_h
|
|
45
|
+
{ number: number, kind: kind, label: label, platform: platform, stage: stage, ordinal: ordinal,
|
|
46
|
+
steps_in_stage: steps_in_stage, instruction: instruction, seconds: seconds, cached: cached,
|
|
47
|
+
bytes: bytes, error: error }
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/dash/cli/app/boot.rb
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
class Dash::Cli::App::Boot
|
|
2
|
+
# What `docker container ls --quiet` prints, and so what dash-proxy has always been
|
|
3
|
+
# handed as a target. `docker run --detach` prints the full 64-character id, so the
|
|
4
|
+
# target is its first twelve characters rather than a round trip of its own.
|
|
5
|
+
SHORT_CONTAINER_ID_LENGTH = 12
|
|
6
|
+
|
|
2
7
|
attr_reader :host, :role, :version, :barrier, :sshkit, :cli
|
|
3
8
|
delegate :execute, :capture_with_info, :capture_with_pretty_json, :info, :error, :upload!, to: :sshkit
|
|
4
9
|
delegate :run_hook, to: :cli
|
|
@@ -38,27 +43,44 @@ class Dash::Cli::App::Boot
|
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
private
|
|
46
|
+
# Both answers come back from one round trip, which means the running version is read
|
|
47
|
+
# before any rename happens. When the clashing container IS the running one, the
|
|
48
|
+
# version to stop later is the name it was renamed to - the name that was read now
|
|
49
|
+
# belongs to the container this boot is about to start.
|
|
41
50
|
def old_version_renamed_if_clashing
|
|
42
|
-
|
|
51
|
+
clashing_container_id, old_version = capture_boot_state
|
|
52
|
+
|
|
53
|
+
if clashing_container_id.present?
|
|
43
54
|
renamed_version = "#{version}_replaced_#{SecureRandom.hex(8)}"
|
|
44
55
|
info "Renaming container #{version} to #{renamed_version} as already deployed on #{host}"
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
execute *auditor.record_then("Renaming container #{version} to #{renamed_version}",
|
|
57
|
+
app.rename_container(version: version, new_version: renamed_version))
|
|
58
|
+
|
|
59
|
+
old_version = renamed_version if old_version == version
|
|
47
60
|
end
|
|
48
61
|
|
|
49
|
-
|
|
62
|
+
old_version
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def capture_boot_state
|
|
66
|
+
clashing, running = Dash::Commands::App.split_state(capture_with_info(*app.boot_state(version), raise_on_non_zero_exit: false))
|
|
67
|
+
|
|
68
|
+
[ clashing.strip.presence, running.strip.presence ]
|
|
50
69
|
end
|
|
51
70
|
|
|
52
71
|
def start_new_version
|
|
53
|
-
audit "Booted app version #{version}"
|
|
54
72
|
hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
|
|
55
73
|
|
|
56
|
-
execute *app.ensure_env_directory
|
|
74
|
+
execute *auditor.record_then("Booted app version #{version}", app.ensure_env_directory)
|
|
57
75
|
upload! role.secrets_io(host), role.secrets_path, mode: "0600"
|
|
58
76
|
|
|
59
|
-
|
|
77
|
+
# `docker run --detach` prints the id of the container it just started, so the
|
|
78
|
+
# proxy target comes out of the run itself — asking docker for it again was a round
|
|
79
|
+
# trip spent re-reading something the host had already said.
|
|
80
|
+
container_id = capture_with_info(*app.run(hostname: hostname)).strip
|
|
81
|
+
|
|
60
82
|
if running_proxy?
|
|
61
|
-
endpoint =
|
|
83
|
+
endpoint = container_id[0, SHORT_CONTAINER_ID_LENGTH]
|
|
62
84
|
raise Dash::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
|
|
63
85
|
|
|
64
86
|
run_hook "pre-proxy-deploy", hosts: host.to_s, role: role.name
|
|
@@ -66,7 +88,7 @@ class Dash::Cli::App::Boot
|
|
|
66
88
|
timing_healthy { execute *app.deploy(target: endpoint) }
|
|
67
89
|
run_hook "post-proxy-deploy", hosts: host.to_s, role: role.name
|
|
68
90
|
else
|
|
69
|
-
timing_healthy { Dash::Cli::Healthcheck::Poller.wait_for_healthy(role: role)
|
|
91
|
+
timing_healthy { Dash::Cli::Healthcheck::Poller.wait_for_healthy(role: role, &method(:readiness_status)) }
|
|
70
92
|
end
|
|
71
93
|
rescue => e
|
|
72
94
|
error "Failed to boot #{role} on #{host}"
|
|
@@ -74,17 +96,21 @@ class Dash::Cli::App::Boot
|
|
|
74
96
|
raise e
|
|
75
97
|
end
|
|
76
98
|
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
# A role behind the proxy lets `dash-proxy deploy` block on the host until the
|
|
100
|
+
# container is healthy; a role without one now does the same, waiting in a shell loop
|
|
101
|
+
# on the host that streams its progress back rather than being polled from here once
|
|
102
|
+
# per attempt. The poller asks for the wait, and — only for an unchecked container it
|
|
103
|
+
# has just let through its readiness delay — for a plain confirming read.
|
|
104
|
+
#
|
|
105
|
+
# Neither capture suppresses a non-zero exit: a status that cannot be read is a broken
|
|
106
|
+
# command, and it has always failed the boot on the spot rather than being waited out.
|
|
107
|
+
def readiness_status(mode, seconds_left = nil)
|
|
108
|
+
if mode == :confirm
|
|
109
|
+
capture_with_info(*app.status(version: version))
|
|
110
|
+
else
|
|
111
|
+
capture_with_info *app.wait_for_ready(version: version, timeout: seconds_left),
|
|
112
|
+
interaction_handler: Dash::Cli::Healthcheck::ProgressReporter.new
|
|
113
|
+
end
|
|
88
114
|
end
|
|
89
115
|
|
|
90
116
|
# Every failed boot gets the container log, and the health probe history when the
|
|
@@ -161,10 +187,6 @@ class Dash::Cli::App::Boot
|
|
|
161
187
|
@auditor = DASH.auditor(role: role)
|
|
162
188
|
end
|
|
163
189
|
|
|
164
|
-
def audit(message)
|
|
165
|
-
execute *auditor.record(message), verbosity: :debug
|
|
166
|
-
end
|
|
167
|
-
|
|
168
190
|
def gatekeeper?
|
|
169
191
|
barrier && barrier_role?
|
|
170
192
|
end
|
data/lib/dash/cli/app.rb
CHANGED
|
@@ -40,8 +40,8 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
40
40
|
|
|
41
41
|
# Tag once the app booted on all hosts
|
|
42
42
|
on(DASH.app_hosts) do |host|
|
|
43
|
-
execute *DASH.auditor.
|
|
44
|
-
|
|
43
|
+
execute *DASH.auditor.record_then("Tagging #{DASH.config.absolute_image} as the latest image",
|
|
44
|
+
DASH.app.tag_latest_image)
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
end
|
|
@@ -186,8 +186,8 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
186
186
|
with_lock_if_stopping do
|
|
187
187
|
on_roles(DASH.roles, hosts: DASH.app_hosts) do |host, role|
|
|
188
188
|
app = DASH.app(role: role, host: host)
|
|
189
|
-
|
|
190
|
-
versions
|
|
189
|
+
listed, running = Dash::Commands::App.split_state(capture_with_info(*app.stale_state, raise_on_non_zero_exit: false))
|
|
190
|
+
versions = listed.strip.split("\n") - [ running.strip ]
|
|
191
191
|
|
|
192
192
|
versions.each do |version|
|
|
193
193
|
if stop
|
data/lib/dash/cli/base.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "thor"
|
|
2
|
+
require "time"
|
|
2
3
|
require "dash/sshkit_with_ext"
|
|
3
4
|
|
|
4
5
|
module Dash::Cli
|
|
@@ -145,13 +146,111 @@ module Dash::Cli
|
|
|
145
146
|
def print_runtime
|
|
146
147
|
started_at = Time.now
|
|
147
148
|
@print_runtime_depth = @print_runtime_depth.to_i + 1
|
|
149
|
+
record_startup_timing if @print_runtime_depth == 1
|
|
148
150
|
yield
|
|
149
151
|
Time.now - started_at
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
# Kept so the saved report says how the run ended. `setup` nests, and the inner
|
|
154
|
+
# deploy sets it first — the outermost writer reads the same error either way.
|
|
155
|
+
@report_error = e
|
|
156
|
+
raise
|
|
150
157
|
ensure
|
|
151
158
|
@print_runtime_depth -= 1
|
|
152
159
|
runtime = Time.now - started_at
|
|
153
160
|
puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
|
|
154
|
-
|
|
161
|
+
finish_report(started_at, runtime) if @print_runtime_depth.zero? && DASH.timings.any?
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Trends, then the table, then the saved JSON. Each half is guarded on its own so a
|
|
165
|
+
# report that cannot be written still prints, and a table that cannot be compared is
|
|
166
|
+
# still a table — none of this is allowed to be why a deploy ends badly.
|
|
167
|
+
def finish_report(started_at, runtime)
|
|
168
|
+
run = nil
|
|
169
|
+
|
|
170
|
+
guarded_report do
|
|
171
|
+
run = report_run(started_at, runtime)
|
|
172
|
+
DASH.report.advice += report_trends(run)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
puts DASH.report.lines
|
|
176
|
+
|
|
177
|
+
guarded_report { write_report(run) } if run
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# What this invocation was, for the saved report and for the trend rules that
|
|
181
|
+
# compare it with the invocations before it.
|
|
182
|
+
def report_run(started_at, runtime)
|
|
183
|
+
{
|
|
184
|
+
command: [ command, subcommand ].compact.join(" "),
|
|
185
|
+
service: DASH.config.service, destination: DASH.config.destination, version: DASH.config.version,
|
|
186
|
+
started_at: started_at.getutc.iso8601, runtime: runtime.round(1),
|
|
187
|
+
status: @report_error ? "failed" : "succeeded",
|
|
188
|
+
error: @report_error && { class: @report_error.class.name, message: @report_error.message }
|
|
189
|
+
}.compact
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def report_trends(run)
|
|
193
|
+
return [] unless DASH.config.report.advice?
|
|
194
|
+
|
|
195
|
+
history = Dash::Report::History.new(reports_directory, destination: DASH.config.destination)
|
|
196
|
+
|
|
197
|
+
Dash::Report::Trends.new(run.merge(phases: DASH.timings.to_h),
|
|
198
|
+
history: history.recent(DASH.config.report.history), ignore: DASH.config.report.ignore).findings
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def write_report(run)
|
|
202
|
+
@report_path = Dash::Report::Writer.new(DASH.report,
|
|
203
|
+
run: run, keep: DASH.config.report.history, directory: reports_directory).write
|
|
204
|
+
|
|
205
|
+
puts " Report written to #{@report_path}" if @report_path
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def reports_directory
|
|
209
|
+
Dash::ProjectDirectory.join("reports")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Summary numbers for the post-deploy hook, so a hook can page on a build that
|
|
213
|
+
# doubled or a warning that appeared without re-deriving any of it. Phases that did
|
|
214
|
+
# not run contribute nothing rather than a zero that reads as "instant".
|
|
215
|
+
def report_hook_details
|
|
216
|
+
guarded_report({}) do
|
|
217
|
+
{
|
|
218
|
+
build_runtime: phase_runtime(Dash::Report::Trends::BUILD_PHASE),
|
|
219
|
+
boot_runtime: phase_runtime(Dash::Report::Trends::BOOT_PHASE),
|
|
220
|
+
advice_count: DASH.report.advice.size.to_s,
|
|
221
|
+
advice_warnings: DASH.report.advice.count(&:warn?).to_s,
|
|
222
|
+
report_path: @report_path
|
|
223
|
+
}.compact
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def phase_runtime(name)
|
|
228
|
+
DASH.timings.seconds_for(name)&.round(1)&.to_s
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Everything that happened before the first phase could be timed: requiring the gem,
|
|
232
|
+
# Zeitwerk, Thor parsing the command line, and building the config. It is the one row
|
|
233
|
+
# an operator cannot influence from deploy.yml, which is exactly why it has to be
|
|
234
|
+
# visible — a five-second startup is dash's problem, not theirs.
|
|
235
|
+
def record_startup_timing
|
|
236
|
+
DASH.timings.record "Startup (load, config)", Process.clock_gettime(Process::CLOCK_MONOTONIC) - Dash::PROCESS_STARTED_AT
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Advice about the Dockerfile, run once the build report is in so measured rules can
|
|
240
|
+
# name real seconds. Nothing here is allowed to end a deploy: a report is a courtesy
|
|
241
|
+
# printed next to the work, and the work already succeeded or failed on its own.
|
|
242
|
+
def analyze_report
|
|
243
|
+
guarded_report { DASH.report.analyze!(DASH.config) }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# `fallback` is what the caller gets when the report could not be produced, for the
|
|
247
|
+
# callers that need a value rather than a side effect.
|
|
248
|
+
def guarded_report(fallback = nil)
|
|
249
|
+
yield
|
|
250
|
+
rescue StandardError => e
|
|
251
|
+
say "Deploy report unavailable: #{e.class}: #{e.message}", :yellow
|
|
252
|
+
say e.backtrace.join("\n"), :yellow if ENV["VERBOSE"]
|
|
253
|
+
fallback
|
|
155
254
|
end
|
|
156
255
|
|
|
157
256
|
def timed(name, depth: 0, &block)
|
|
@@ -204,6 +303,10 @@ module Dash::Cli
|
|
|
204
303
|
# in one `on(hosts)` sweep would leave the locks we did win in place, and
|
|
205
304
|
# every retry would then collide with itself and wait out the timeout.
|
|
206
305
|
def acquire_server_lock
|
|
306
|
+
timed("Acquire server lock") { acquire_server_lock_now }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def acquire_server_lock_now
|
|
207
310
|
ensure_run_directory
|
|
208
311
|
|
|
209
312
|
timeout = DASH.lock_wait_timeout
|
|
@@ -343,6 +446,10 @@ module Dash::Cli
|
|
|
343
446
|
end
|
|
344
447
|
|
|
345
448
|
def acquire_lock
|
|
449
|
+
timed("Acquire deploy lock") { acquire_lock_now }
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def acquire_lock_now
|
|
346
453
|
ensure_run_directory
|
|
347
454
|
|
|
348
455
|
if DASH.lock_wait
|
|
@@ -470,10 +577,18 @@ module Dash::Cli
|
|
|
470
577
|
instance_variable_get("@_invocations")[cli_class].pop
|
|
471
578
|
end
|
|
472
579
|
|
|
580
|
+
# Every lock acquire wants the run directory to exist, but the sweep is idempotent
|
|
581
|
+
# and a process only needs it once per host - the deploy lock and the server lock
|
|
582
|
+
# were paying for it twice.
|
|
473
583
|
def ensure_run_directory
|
|
474
|
-
|
|
584
|
+
pending = DASH.hosts.map(&:to_s) - DASH.run_directory_ensured_on
|
|
585
|
+
return if pending.empty?
|
|
586
|
+
|
|
587
|
+
on(pending) do
|
|
475
588
|
execute(*DASH.server.ensure_run_directory)
|
|
476
589
|
end
|
|
590
|
+
|
|
591
|
+
DASH.run_directory_ensured_on.concat(pending)
|
|
477
592
|
end
|
|
478
593
|
|
|
479
594
|
def with_env(env)
|