dash 4.0.7 → 4.1.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/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 +20 -10
- data/lib/dash/cli/app.rb +2 -2
- data/lib/dash/cli/base.rb +117 -2
- data/lib/dash/cli/build.rb +61 -5
- data/lib/dash/cli/doctor/config_checks.rb +36 -1
- data/lib/dash/cli/doctor.rb +2 -1
- data/lib/dash/cli/main.rb +24 -7
- 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 +18 -0
- data/lib/dash/commands/auditor.rb +10 -0
- data/lib/dash/commands/builder/base.rb +18 -0
- data/lib/dash/commands/builder.rb +1 -1
- data/lib/dash/configuration/docs/configuration.yml +6 -0
- data/lib/dash/configuration/docs/report.yml +39 -0
- data/lib/dash/configuration/docs/ssh.yml +8 -0
- data/lib/dash/configuration/docs/sshkit.yml +2 -1
- data/lib/dash/configuration/report.rb +65 -0
- data/lib/dash/configuration/ssh.rb +8 -1
- 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 +77 -4
- 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 +36 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 32799cf5de0b149a2b36f88e0af2d671c82a5d05a3c54180f034d8574eb58bf8
|
|
4
|
+
data.tar.gz: 1e33d017d9ec6e3e3545ea0c1125d9d4b38a2e12557197b2c827e00e608b4aa0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bac661834cb626277175f2e0a9abbfb273975a0e70dd45779352798f547c5097ff836cfaed017316a207d59aa4d62b1b337112692db9fa7870aec2056bcd14e4
|
|
7
|
+
data.tar.gz: a8879e8f7b3250903eb854f23436b63892cdced0a303969039ebc809f29cce5231f3dda51faa2c5d3755b55edb8b543a007f96964272dc7afe1a1cf6c132ec6b
|
|
@@ -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
|
@@ -38,22 +38,36 @@ class Dash::Cli::App::Boot
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
private
|
|
41
|
+
# Both answers come back from one round trip, which means the running version is read
|
|
42
|
+
# before any rename happens. When the clashing container IS the running one, the
|
|
43
|
+
# version to stop later is the name it was renamed to - the name that was read now
|
|
44
|
+
# belongs to the container this boot is about to start.
|
|
41
45
|
def old_version_renamed_if_clashing
|
|
42
|
-
|
|
46
|
+
clashing_container_id, old_version = capture_boot_state
|
|
47
|
+
|
|
48
|
+
if clashing_container_id.present?
|
|
43
49
|
renamed_version = "#{version}_replaced_#{SecureRandom.hex(8)}"
|
|
44
50
|
info "Renaming container #{version} to #{renamed_version} as already deployed on #{host}"
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
execute *auditor.record_then("Renaming container #{version} to #{renamed_version}",
|
|
52
|
+
app.rename_container(version: version, new_version: renamed_version))
|
|
53
|
+
|
|
54
|
+
old_version = renamed_version if old_version == version
|
|
47
55
|
end
|
|
48
56
|
|
|
49
|
-
|
|
57
|
+
old_version
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def capture_boot_state
|
|
61
|
+
output = capture_with_info(*app.boot_state(version), raise_on_non_zero_exit: false).to_s
|
|
62
|
+
clashing, _, running = output.partition(/^#{Regexp.escape(Dash::Commands::App::BOOT_STATE_SEPARATOR)}$/)
|
|
63
|
+
|
|
64
|
+
[ clashing.strip.presence, running.strip.presence ]
|
|
50
65
|
end
|
|
51
66
|
|
|
52
67
|
def start_new_version
|
|
53
|
-
audit "Booted app version #{version}"
|
|
54
68
|
hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
|
|
55
69
|
|
|
56
|
-
execute *app.ensure_env_directory
|
|
70
|
+
execute *auditor.record_then("Booted app version #{version}", app.ensure_env_directory)
|
|
57
71
|
upload! role.secrets_io(host), role.secrets_path, mode: "0600"
|
|
58
72
|
|
|
59
73
|
execute *app.run(hostname: hostname)
|
|
@@ -161,10 +175,6 @@ class Dash::Cli::App::Boot
|
|
|
161
175
|
@auditor = DASH.auditor(role: role)
|
|
162
176
|
end
|
|
163
177
|
|
|
164
|
-
def audit(message)
|
|
165
|
-
execute *auditor.record(message), verbosity: :debug
|
|
166
|
-
end
|
|
167
|
-
|
|
168
178
|
def gatekeeper?
|
|
169
179
|
barrier && barrier_role?
|
|
170
180
|
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
|
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)
|
data/lib/dash/cli/build.rb
CHANGED
|
@@ -37,6 +37,9 @@ class Dash::Cli::Build < Dash::Cli::Base
|
|
|
37
37
|
say "Building with uncommitted changes:\n #{uncommitted_changes}", :yellow
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
parser = build_progress_parser
|
|
41
|
+
handler = handler_for(parser)
|
|
42
|
+
|
|
40
43
|
forward_local_registry_port_for_remote_builder do
|
|
41
44
|
with_env(DASH.config.builder.secrets) do
|
|
42
45
|
run_locally do
|
|
@@ -60,11 +63,13 @@ class Dash::Cli::Build < Dash::Cli::Base
|
|
|
60
63
|
push = DASH.builder.push(cli.options[:output], no_cache: cli.options[:no_cache])
|
|
61
64
|
|
|
62
65
|
DASH.with_verbosity(:debug) do
|
|
63
|
-
Dir.chdir(DASH.config.builder.build_directory) { execute *push, env: DASH.builder.push_env }
|
|
66
|
+
Dir.chdir(DASH.config.builder.build_directory) { execute *push, env: DASH.builder.push_env, **handler }
|
|
64
67
|
end
|
|
65
68
|
end
|
|
66
69
|
end
|
|
67
70
|
end
|
|
71
|
+
ensure
|
|
72
|
+
record_build_report parser
|
|
68
73
|
end
|
|
69
74
|
|
|
70
75
|
desc "pull", "Pull app image from registry onto servers"
|
|
@@ -147,17 +152,66 @@ class Dash::Cli::Build < Dash::Cli::Base
|
|
|
147
152
|
say
|
|
148
153
|
end
|
|
149
154
|
|
|
155
|
+
parser = build_progress_parser
|
|
156
|
+
handler = handler_for(parser)
|
|
157
|
+
|
|
150
158
|
with_env(DASH.config.builder.secrets) do
|
|
151
159
|
run_locally do
|
|
152
160
|
build = DASH.builder.push(cli.options[:output], tag_as_dirty: true, no_cache: cli.options[:no_cache])
|
|
153
161
|
DASH.with_verbosity(:debug) do
|
|
154
|
-
execute(*build)
|
|
162
|
+
execute(*build, **handler)
|
|
155
163
|
end
|
|
156
164
|
end
|
|
157
165
|
end
|
|
166
|
+
ensure
|
|
167
|
+
# `dev` builds the working directory even when `push` would clone, so the advice reads
|
|
168
|
+
# the Dockerfile here rather than in a clone that may not exist.
|
|
169
|
+
record_build_report parser, build_directory: "."
|
|
158
170
|
end
|
|
159
171
|
|
|
160
172
|
private
|
|
173
|
+
# Buildpacks print a total and nothing else, so there is nothing for the parser to
|
|
174
|
+
# read and no reason to attach it.
|
|
175
|
+
def build_progress_parser
|
|
176
|
+
Dash::Build::ProgressParser.new unless DASH.builder.pack?
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def handler_for(parser)
|
|
180
|
+
parser ? { interaction_handler: parser } : {}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Runs whether the build succeeded or failed: a partial report naming the step that
|
|
184
|
+
# broke is exactly what an operator wants from a failed build. Measurement must never
|
|
185
|
+
# be the reason a build fails, so nothing in here is allowed to raise.
|
|
186
|
+
def record_build_report(parser, build_directory: DASH.config.builder.build_directory)
|
|
187
|
+
return unless parser
|
|
188
|
+
|
|
189
|
+
guarded_report do
|
|
190
|
+
parser.finish
|
|
191
|
+
DASH.report.build = parser.result
|
|
192
|
+
|
|
193
|
+
say "Deploy report unavailable: #{parser.error.class}: #{parser.error.message}", :yellow if parser.error
|
|
194
|
+
|
|
195
|
+
# Inside a deploy the phase table prints these at the end and the deploy runs its
|
|
196
|
+
# own analysis once the image is delivered. Standalone, this is the only chance.
|
|
197
|
+
unless DASH.report.build_entry
|
|
198
|
+
print_build_report
|
|
199
|
+
DASH.report.analyze!(DASH.config, build_directory: build_directory)
|
|
200
|
+
puts DASH.report.advice_lines
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A standalone `dash build push` has no phase table to sit under, but a CI pipeline
|
|
206
|
+
# that splits build from deploy should still see where the build time went.
|
|
207
|
+
def print_build_report
|
|
208
|
+
rows = DASH.report.build_lines
|
|
209
|
+
return if rows.empty?
|
|
210
|
+
|
|
211
|
+
puts " Build"
|
|
212
|
+
puts rows
|
|
213
|
+
end
|
|
214
|
+
|
|
161
215
|
def connect_to_remote_host(remote_host)
|
|
162
216
|
remote_uri = URI.parse(remote_host)
|
|
163
217
|
if remote_uri.scheme == "ssh"
|
|
@@ -186,11 +240,13 @@ class Dash::Cli::Build < Dash::Cli::Base
|
|
|
186
240
|
end
|
|
187
241
|
end
|
|
188
242
|
|
|
243
|
+
# Audit, clean and pull share one round trip. validate_image keeps its own: folding it
|
|
244
|
+
# in would put the pull under validate_image's trailing `|| (echo ... && exit 1)`, and
|
|
245
|
+
# a failed pull would then report a missing service label.
|
|
189
246
|
def pull_on_hosts(hosts)
|
|
190
247
|
on(hosts) do
|
|
191
|
-
execute *DASH.auditor.
|
|
192
|
-
|
|
193
|
-
execute *DASH.builder.pull
|
|
248
|
+
execute *DASH.auditor.record_then("Pulled image with version #{DASH.config.version}",
|
|
249
|
+
DASH.builder.clean_then_pull)
|
|
194
250
|
execute *DASH.builder.validate_image
|
|
195
251
|
end
|
|
196
252
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# that are visible in deploy.yml alone, so they still run when every host is unreachable.
|
|
3
3
|
class Dash::Cli::Doctor::ConfigChecks
|
|
4
4
|
def run
|
|
5
|
-
readiness_results
|
|
5
|
+
readiness_results + dockerfile_results
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
private
|
|
@@ -25,4 +25,39 @@ class Dash::Cli::Doctor::ConfigChecks
|
|
|
25
25
|
"add a `healthcheck:` block, or opt out with `healthcheck: false`"
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
# The static half of the deploy report's advice: the same rules, without a build to
|
|
30
|
+
# measure against. `advice: false` is not consulted — it silences the block printed
|
|
31
|
+
# next to a deploy, and this check is one the operator asked for by name.
|
|
32
|
+
def dockerfile_results
|
|
33
|
+
dockerfile = DASH.config.builder.dockerfile
|
|
34
|
+
path = File.expand_path(dockerfile)
|
|
35
|
+
|
|
36
|
+
unless File.exist?(path)
|
|
37
|
+
return [ result(:dockerfile, dockerfile, :fail, "not found — `dash build push` fails with Missing #{dockerfile}") ]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
findings = analyzer(dockerfile, path).findings
|
|
41
|
+
return [ result(:dockerfile, dockerfile, :ok, "no findings") ] if findings.empty?
|
|
42
|
+
|
|
43
|
+
findings.map { |finding| finding_result(finding) }
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
[ result(:dockerfile, dockerfile, :warn, "could not be analysed (#{e.class}: #{e.message})") ]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def analyzer(dockerfile, path)
|
|
49
|
+
Dash::Dockerfile::Analyzer.new \
|
|
50
|
+
document: Dash::Dockerfile::Parser.parse(File.read(path)),
|
|
51
|
+
path: dockerfile,
|
|
52
|
+
context_dir: File.expand_path(DASH.config.builder.context),
|
|
53
|
+
builder: DASH.config.builder,
|
|
54
|
+
ignore: DASH.config.report.ignore,
|
|
55
|
+
hadolint: DASH.config.report.hadolint?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# An informational finding is not a reason to hold up a deploy, so it reports as ok
|
|
59
|
+
# with its message intact — visible, but never the difference between ready and not.
|
|
60
|
+
def finding_result(finding)
|
|
61
|
+
result :dockerfile, finding.location, (finding.warn? ? :warn : :ok), "#{finding.message} [#{finding.rule}]"
|
|
62
|
+
end
|
|
28
63
|
end
|