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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Compares this run with the last few saved reports of the same command and destination.
|
|
2
|
+
#
|
|
3
|
+
# The Dockerfile rules can only see what one build looked like; these see what a project's
|
|
4
|
+
# deploys usually look like, which is the only way to tell "the build takes 84 seconds"
|
|
5
|
+
# from "the build suddenly takes twice what it used to". Everything here is informational:
|
|
6
|
+
# a slow deploy is a fact about today, not a defect to fix.
|
|
7
|
+
#
|
|
8
|
+
# The phase names are the ones a deploy records in Dash::Cli::Main and Dash::Cli::Base.
|
|
9
|
+
# They are pinned from the other side by test/cli/main_test.rb, which asserts the table a
|
|
10
|
+
# real deploy prints, so a rename cannot quietly turn these rules off.
|
|
11
|
+
class Dash::Report::Trends
|
|
12
|
+
BUILD_PHASE = "Build and push app image".freeze
|
|
13
|
+
BOOT_PHASE = "Boot".freeze
|
|
14
|
+
# Everything a deploy spends before and around the work itself: loading the gem, parsing
|
|
15
|
+
# the config, resolving secrets, and taking the locks.
|
|
16
|
+
OVERHEAD_PHASES = [ "Startup (load, config)", "Validate config and secrets",
|
|
17
|
+
"Acquire deploy lock", "Acquire server lock" ].freeze
|
|
18
|
+
|
|
19
|
+
# Two deploys are an anecdote. Three are the fewest that can have a median worth
|
|
20
|
+
# comparing against.
|
|
21
|
+
MINIMUM_HISTORY = 3
|
|
22
|
+
WINDOW = 5
|
|
23
|
+
FACTOR = 1.5
|
|
24
|
+
# Overhead this large is worth naming even to a project whose deploys have always
|
|
25
|
+
# carried it — "normal" is not the same as "cheap".
|
|
26
|
+
OVERHEAD_FLOOR = 10.0
|
|
27
|
+
|
|
28
|
+
LOCATION = "deploy history".freeze
|
|
29
|
+
|
|
30
|
+
def initialize(document, history: [], ignore: [])
|
|
31
|
+
@document = document
|
|
32
|
+
@history = comparable(history)
|
|
33
|
+
@ignore = Array(ignore).map(&:to_s)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def findings
|
|
37
|
+
return [] if @history.size < MINIMUM_HISTORY
|
|
38
|
+
|
|
39
|
+
[ build_finding, boot_finding, overhead_finding, total_finding ].compact.reject { |finding| @ignore.include?(finding.rule) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
def comparable(history)
|
|
44
|
+
Array(history)
|
|
45
|
+
.map { |document| document.transform_keys(&:to_sym) }
|
|
46
|
+
.select { |document| document[:command] == @document[:command] && document[:status] == "succeeded" }
|
|
47
|
+
.first(WINDOW)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build_finding
|
|
51
|
+
phase_finding "trend-build", "build", BUILD_PHASE
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def boot_finding
|
|
55
|
+
phase_finding "trend-boot", "boot", BOOT_PHASE
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def phase_finding(rule, label, name)
|
|
59
|
+
current = phase_seconds(@document, name) or return
|
|
60
|
+
past = @history.filter_map { |document| phase_seconds(document, name) }
|
|
61
|
+
return if past.size < MINIMUM_HISTORY || current <= median(past) * FACTOR
|
|
62
|
+
|
|
63
|
+
note rule, "#{label} #{seconds(current)} vs median #{seconds(median(past))} over the last #{past.size} deploys"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def total_finding
|
|
67
|
+
current = @document[:runtime].to_f
|
|
68
|
+
past = @history.map { |document| document[:runtime].to_f }
|
|
69
|
+
return if current <= median(past) * FACTOR
|
|
70
|
+
|
|
71
|
+
note "trend-total", "total #{seconds(current)} vs median #{seconds(median(past))} over the last #{past.size} deploys"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Fires on the absolute number as well as the trend, and names the row responsible —
|
|
75
|
+
# ten seconds before the first docker command is nearly always one slow thing (a
|
|
76
|
+
# secrets adapter shelling out, a lock waiting on a sweep) rather than a diffuse cost.
|
|
77
|
+
def overhead_finding
|
|
78
|
+
current = overhead_seconds(@document)
|
|
79
|
+
past = @history.map { |document| overhead_seconds(document) }
|
|
80
|
+
return if current < OVERHEAD_FLOOR && current <= median(past) * FACTOR
|
|
81
|
+
|
|
82
|
+
message = "dash overhead #{seconds(current)} vs median #{seconds(median(past))} over the last #{past.size} deploys"
|
|
83
|
+
if (slowest = slowest_overhead_phase)
|
|
84
|
+
message += "; #{slowest[:name]} was #{seconds(slowest[:seconds])} of it"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
note "trend-overhead", message, "a secrets adapter or a lock sweep is the usual cause"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def overhead_seconds(document)
|
|
91
|
+
overhead_phases(document).sum { |phase| phase[:seconds].to_f }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def slowest_overhead_phase
|
|
95
|
+
overhead_phases(@document).max_by { |phase| phase[:seconds].to_f }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def overhead_phases(document)
|
|
99
|
+
Array(document[:phases])
|
|
100
|
+
.map { |phase| phase.transform_keys(&:to_sym) }
|
|
101
|
+
.select { |phase| OVERHEAD_PHASES.include?(phase[:name]) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Only depth-0 rows: a per-host row inside Boot is named after the host, but a role
|
|
105
|
+
# could be named "Boot" and would otherwise be counted as the phase.
|
|
106
|
+
def phase_seconds(document, name)
|
|
107
|
+
phase = Array(document[:phases])
|
|
108
|
+
.map { |candidate| candidate.transform_keys(&:to_sym) }
|
|
109
|
+
.find { |candidate| candidate[:name] == name && candidate[:depth].to_i.zero? }
|
|
110
|
+
|
|
111
|
+
phase[:seconds].to_f if phase
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def median(values)
|
|
115
|
+
sorted = values.sort
|
|
116
|
+
middle = sorted.size / 2
|
|
117
|
+
|
|
118
|
+
sorted.size.odd? ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2.0
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def seconds(value)
|
|
122
|
+
format("%.1fs", value.to_f)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def note(rule, message, suggestion = nil)
|
|
126
|
+
Dash::Dockerfile::Finding.new \
|
|
127
|
+
rule: rule, severity: :info, location: LOCATION, message: message, suggestion: suggestion
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
# Writes one JSON report per run under `.dash/reports`, so the next deploy has something
|
|
5
|
+
# to compare itself against and CI has something to archive.
|
|
6
|
+
#
|
|
7
|
+
# A failed deploy is written too, with whatever was measured before it broke: the run an
|
|
8
|
+
# operator most wants to look at afterwards is the one that went wrong.
|
|
9
|
+
class Dash::Report::Writer
|
|
10
|
+
GITIGNORE = "*\n!.gitignore\n".freeze
|
|
11
|
+
|
|
12
|
+
# A destination is an operator-supplied string and a command can carry a subcommand, so
|
|
13
|
+
# neither is trusted to be a filename.
|
|
14
|
+
UNSAFE = /[^\w.-]+/
|
|
15
|
+
|
|
16
|
+
attr_reader :report, :run, :keep, :directory
|
|
17
|
+
|
|
18
|
+
# `run` is the metadata Dash::Cli::Base assembled for this invocation — the same hash
|
|
19
|
+
# the trend rules compared against, so the file and the advice cannot disagree.
|
|
20
|
+
def initialize(report, run:, keep:, directory: Dash::ProjectDirectory.join("reports"))
|
|
21
|
+
@report = report
|
|
22
|
+
@run = run
|
|
23
|
+
@keep = keep
|
|
24
|
+
@directory = directory
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns the path it wrote, or nil when the operator turned history off.
|
|
28
|
+
def write
|
|
29
|
+
return if keep.zero?
|
|
30
|
+
|
|
31
|
+
prepare_directory
|
|
32
|
+
@path = publish JSON.pretty_generate(report.to_h(**run))
|
|
33
|
+
Dash::Report::History.new(directory, destination: run[:destination]).prune(keep)
|
|
34
|
+
|
|
35
|
+
@path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
# The name and the content arrive together, always. A hard link publishes a file
|
|
40
|
+
# that is already complete, atomically, and only if the name is free — so two runs
|
|
41
|
+
# racing for one name (started_at is recorded to the second, and `safe` maps
|
|
42
|
+
# `eu/west` and `eu-west` onto the same string) each keep their own report, and
|
|
43
|
+
# there is never a moment where a report exists empty or half-written. That last
|
|
44
|
+
# part matters more than it sounds: every reader skips a file it cannot parse, and
|
|
45
|
+
# the prune only counts the files it could read, so anything left behind here would
|
|
46
|
+
# stay in the directory forever.
|
|
47
|
+
def publish(content)
|
|
48
|
+
scratch = File.join(directory, ".#{base_name}.#{Process.pid}.tmp")
|
|
49
|
+
File.write(scratch, content)
|
|
50
|
+
|
|
51
|
+
begin
|
|
52
|
+
linked(scratch)
|
|
53
|
+
rescue SystemCallError
|
|
54
|
+
created(scratch)
|
|
55
|
+
end
|
|
56
|
+
ensure
|
|
57
|
+
File.delete(scratch) if scratch && File.exist?(scratch)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def linked(scratch)
|
|
61
|
+
claim do |candidate|
|
|
62
|
+
File.link(scratch, candidate)
|
|
63
|
+
candidate
|
|
64
|
+
rescue Errno::EEXIST
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# For a filesystem with no hard links. The name is claimed with an exclusive create —
|
|
70
|
+
# which never replaces, so another run's report is safe — and the completed scratch
|
|
71
|
+
# file is then renamed onto that placeholder, atomically, so no reader ever sees a
|
|
72
|
+
# half-written report. The placeholder is the one thing that can be left behind
|
|
73
|
+
# here, and it is the one thing nothing would ever clean up, so it comes down in an
|
|
74
|
+
# `ensure`: a full disk and the operator's Ctrl-C (an Interrupt, not a
|
|
75
|
+
# StandardError) both take the claimed name with them.
|
|
76
|
+
def created(scratch)
|
|
77
|
+
claim do |candidate|
|
|
78
|
+
next unless placeholder?(candidate)
|
|
79
|
+
|
|
80
|
+
filled(candidate, scratch)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def placeholder?(candidate)
|
|
85
|
+
File.open(candidate, File::WRONLY | File::CREAT | File::EXCL) { }
|
|
86
|
+
true
|
|
87
|
+
rescue Errno::EEXIST
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The cleanup asks the filesystem what happened rather than trusting a flag set after
|
|
92
|
+
# the fact: the rename consumes the scratch file, so a scratch that is still there is
|
|
93
|
+
# a rename that did not happen. A flag would have its own window — an interrupt
|
|
94
|
+
# between a successful rename and the assignment would delete a published report.
|
|
95
|
+
def filled(candidate, scratch)
|
|
96
|
+
File.rename(scratch, candidate)
|
|
97
|
+
|
|
98
|
+
candidate
|
|
99
|
+
ensure
|
|
100
|
+
File.delete(candidate) if File.exist?(scratch) && File.exist?(candidate)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Walks the candidate names until the block takes one, yielding nil for a name that
|
|
104
|
+
# was already gone. Suffixed names sort after the one they collided with, which
|
|
105
|
+
# Dash::Report::History#order_key relies on to read them back in run order.
|
|
106
|
+
def claim
|
|
107
|
+
suffix = 1
|
|
108
|
+
candidate = File.join(directory, "#{base_name}.json")
|
|
109
|
+
|
|
110
|
+
until (claimed = yield candidate)
|
|
111
|
+
candidate = File.join(directory, "#{base_name}-#{suffix += 1}.json")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
claimed
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def base_name
|
|
118
|
+
"#{timestamp}-#{safe(destination)}-#{safe(run[:command])}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The started_at the report already carries, with the colons a filename cannot have.
|
|
122
|
+
def timestamp
|
|
123
|
+
safe(run[:started_at].to_s.tr(":", "-"))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def destination
|
|
127
|
+
run[:destination].presence || "default"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def safe(part)
|
|
131
|
+
part.to_s.gsub(UNSAFE, "-")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The .gitignore is written next to the reports rather than left to the operator: a
|
|
135
|
+
# project that commits `.dash/` would otherwise start committing a deploy report on
|
|
136
|
+
# every deploy, and nobody asked for that in their diff.
|
|
137
|
+
def prepare_directory
|
|
138
|
+
FileUtils.mkdir_p(directory)
|
|
139
|
+
gitignore = File.join(directory, ".gitignore")
|
|
140
|
+
File.write(gitignore, GITIGNORE) unless File.exist?(gitignore)
|
|
141
|
+
end
|
|
142
|
+
end
|
data/lib/dash/report.rb
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
require "active_support/core_ext/string/filters"
|
|
2
|
+
require "active_support/core_ext/module/delegation"
|
|
3
|
+
|
|
4
|
+
# The deploy report: the phase table plus everything measured inside a phase that the
|
|
5
|
+
# table itself has no column for. Today that is the build; the Dockerfile advice and the
|
|
6
|
+
# JSON export hang off the same object.
|
|
7
|
+
#
|
|
8
|
+
# It owns the rendering rather than Timings because the build rows have to be spliced
|
|
9
|
+
# into the middle of the table, under the phase they belong to, and Timings has no
|
|
10
|
+
# business knowing what a buildx vertex is.
|
|
11
|
+
class Dash::Report
|
|
12
|
+
delegate :human_bytes, to: Dash::Utils
|
|
13
|
+
|
|
14
|
+
# Depth-1 rows, so build steps line up with the per-host rows a Boot phase prints.
|
|
15
|
+
INDENT = " "
|
|
16
|
+
# Wider than the phase table's 36-column name, because a Dockerfile instruction is the
|
|
17
|
+
# whole point of the row and most of them are longer than that. The build rows line up
|
|
18
|
+
# with each other as their own block under the phase; 4 + 60 + 8 still fits 80 columns.
|
|
19
|
+
NAME_WIDTH = 60
|
|
20
|
+
VALUE_WIDTH = 7
|
|
21
|
+
SLOWEST_STEPS = 5
|
|
22
|
+
|
|
23
|
+
# Severity, then the file or key to open, then the sentence. The suggestion hangs under
|
|
24
|
+
# the sentence so the eye can skip the whole block or read one finding in full.
|
|
25
|
+
SEVERITY_WIDTH = 4
|
|
26
|
+
LOCATION_WIDTH = 15
|
|
27
|
+
SEVERITY_COLORS = { warn: "\e[33m" }.freeze
|
|
28
|
+
|
|
29
|
+
# The version of the JSON documents #to_h writes and #from_h reads. Bumping it is a
|
|
30
|
+
# promise to whatever reads .dash/reports, so a reader that does not recognise the
|
|
31
|
+
# number skips the file rather than guessing.
|
|
32
|
+
SCHEMA = 1
|
|
33
|
+
|
|
34
|
+
attr_reader :timings
|
|
35
|
+
attr_accessor :build, :build_entry, :advice
|
|
36
|
+
|
|
37
|
+
# Rebuilds a saved report so `dash report` prints it the way the deploy printed it —
|
|
38
|
+
# same table, same build rows under the same phase, same advice.
|
|
39
|
+
def self.from_h(document)
|
|
40
|
+
document = document.transform_keys(&:to_sym)
|
|
41
|
+
timings = Dash::Timings.from_h(document[:phases])
|
|
42
|
+
|
|
43
|
+
new(timings: timings).tap do |report|
|
|
44
|
+
report.build_entry = timings.entry_at(document[:build_phase])
|
|
45
|
+
report.build = Dash::Build::Report.from_h(document[:build]) if document[:build]
|
|
46
|
+
report.advice = Array(document[:advice]).map { |finding| Dash::Dockerfile::Finding.from_h(finding) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialize(timings:)
|
|
51
|
+
@timings = timings
|
|
52
|
+
@advice = []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The run's own facts (command, service, destination, version, timings of the whole
|
|
56
|
+
# thing) belong to the caller that knows them; the report contributes what it measured.
|
|
57
|
+
# `build_phase` is the row the build rows hang under, by position, so a reader can put
|
|
58
|
+
# them back without matching on a phase name dash is free to reword.
|
|
59
|
+
def to_h(**run)
|
|
60
|
+
{
|
|
61
|
+
schema: SCHEMA, dash_version: Dash::VERSION, **run,
|
|
62
|
+
phases: timings.to_h, build_phase: build_entry && timings.index_of(build_entry),
|
|
63
|
+
build: build&.to_h, advice: advice.map(&:to_h)
|
|
64
|
+
}.compact
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def lines
|
|
68
|
+
lines = timings.lines
|
|
69
|
+
rows = build_lines
|
|
70
|
+
|
|
71
|
+
unless rows.empty?
|
|
72
|
+
index = build_entry && timings.index_of(build_entry)
|
|
73
|
+
lines = index ? lines.insert(index + 1, *rows) : lines + rows
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
lines + advice_lines
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Runs the Dockerfile rules against the file this deploy would build, upgraded with what
|
|
80
|
+
# the build measured when there was one. Silent about a Dockerfile that is not there:
|
|
81
|
+
# a --skip-push deploy never looks at one, and a missing file is `dash doctor`'s finding
|
|
82
|
+
# to report, not a deploy's.
|
|
83
|
+
#
|
|
84
|
+
# `build_directory` is where the Dockerfile and context are read from: the git clone for
|
|
85
|
+
# a `push`, the working directory for a `dev` build that never clones.
|
|
86
|
+
def analyze!(config, build: @build, build_directory: config.builder.build_directory)
|
|
87
|
+
@advice = []
|
|
88
|
+
return unless config.report.advice?
|
|
89
|
+
|
|
90
|
+
dockerfile = File.expand_path(config.builder.dockerfile, build_directory)
|
|
91
|
+
return unless File.exist?(dockerfile)
|
|
92
|
+
|
|
93
|
+
@advice = Dash::Dockerfile::Analyzer.new(
|
|
94
|
+
document: Dash::Dockerfile::Parser.parse(File.read(dockerfile)),
|
|
95
|
+
path: config.builder.dockerfile,
|
|
96
|
+
file: dockerfile,
|
|
97
|
+
context_dir: File.expand_path(config.builder.context, build_directory),
|
|
98
|
+
build: build,
|
|
99
|
+
builder: config.builder,
|
|
100
|
+
ignore: config.report.ignore,
|
|
101
|
+
hadolint: config.report.hadolint?
|
|
102
|
+
).findings
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Also printed on their own by `dash build push`, for the same reason the build rows are.
|
|
106
|
+
def advice_lines
|
|
107
|
+
return [] if advice.blank?
|
|
108
|
+
|
|
109
|
+
[ " Advice", *advice.flat_map { |finding| advice_rows(finding) } ]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Also printed on their own by `dash build push`, which has no phase table to sit under.
|
|
113
|
+
def build_lines
|
|
114
|
+
return [] unless build&.any?
|
|
115
|
+
|
|
116
|
+
rows = []
|
|
117
|
+
rows << context_row if build.context_bytes
|
|
118
|
+
build.slowest(SLOWEST_STEPS).select(&:seconds).each { |step| rows << row(step.label, seconds(step.seconds)) }
|
|
119
|
+
rows << row("cached steps", "#{build.cached_steps.size} of #{build.dockerfile_steps.size}") if build.dockerfile_steps.any?
|
|
120
|
+
rows << export_row if export_and_push_seconds > 0
|
|
121
|
+
# A multi-platform build runs the same instruction once per platform, so one broken
|
|
122
|
+
# step fails once per platform with the same message. Print that once.
|
|
123
|
+
build.failed_steps.uniq { |step| [ step.label, step.error ] }.each { |step| rows << row(step.label, "error", step.error) }
|
|
124
|
+
rows
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
# The suggestion hangs under the message rather than under the row, so a location
|
|
129
|
+
# longer than its column (a Dockerfile somewhere deep in the tree) shifts both.
|
|
130
|
+
def advice_rows(finding)
|
|
131
|
+
prefix = format("%s%-#{SEVERITY_WIDTH}s %-#{LOCATION_WIDTH}s ", INDENT, finding.severity, finding.location)
|
|
132
|
+
|
|
133
|
+
rows = [ colorize(finding.severity, "#{prefix}#{finding.message}") ]
|
|
134
|
+
rows << "#{" " * prefix.length}→ #{finding.suggestion}" if finding.suggestion.present?
|
|
135
|
+
rows
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Colour is for the terminal only: a report piped to a file or asserted in a test
|
|
139
|
+
# should be the same text without the escape codes.
|
|
140
|
+
def colorize(severity, line)
|
|
141
|
+
color = SEVERITY_COLORS[severity] if $stdout.tty?
|
|
142
|
+
color ? "#{color}#{line}\e[0m" : line
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# A build killed between the context transfer and that vertex's DONE has a size but
|
|
146
|
+
# no duration. "0.0s" would be a measurement nobody took.
|
|
147
|
+
def context_row
|
|
148
|
+
row "build context", build.context_seconds ? seconds(build.context_seconds) : "n/a", human_bytes(build.context_bytes)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def export_row
|
|
152
|
+
note = "cache export #{seconds(build.cache_export_seconds)}" if build.cache_export_seconds > 0
|
|
153
|
+
row "export + push", seconds(export_and_push_seconds), note
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def export_and_push_seconds
|
|
157
|
+
build.export_seconds + build.push_seconds
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Indented like a Dash::Timings depth-1 row, with a wider name column of its own; a
|
|
161
|
+
# step longer than that is cut rather than allowed to run off the line.
|
|
162
|
+
def row(name, value, detail = nil)
|
|
163
|
+
line = format("%s%-#{NAME_WIDTH}s %#{VALUE_WIDTH}s", INDENT, name.truncate(NAME_WIDTH), value)
|
|
164
|
+
detail ? "#{line} (#{detail})" : line
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def seconds(value)
|
|
168
|
+
format("%.1fs", value.to_f)
|
|
169
|
+
end
|
|
170
|
+
end
|
data/lib/dash/sshkit_with_ext.rb
CHANGED
|
@@ -6,6 +6,27 @@ require "json"
|
|
|
6
6
|
require "resolv"
|
|
7
7
|
require "concurrent/atomic/semaphore"
|
|
8
8
|
|
|
9
|
+
# Deploy-report timing reaches into dash's runtime from code that runs for everyone else
|
|
10
|
+
# using SSHKit in this process too, and this file can be required on its own — without the
|
|
11
|
+
# gem's autoloader and without the DASH commander. So every reach is optional, and it is
|
|
12
|
+
# funnelled through here rather than repeated at each hook, where one site would sooner or
|
|
13
|
+
# later forget the guard and take down every parallel run with a NameError.
|
|
14
|
+
module DashTimings
|
|
15
|
+
class << self
|
|
16
|
+
def timings
|
|
17
|
+
DASH.timings if defined?(DASH)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def current_entry
|
|
21
|
+
Dash::Timings.current_entry if defined?(Dash::Timings)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def current_entry=(entry)
|
|
25
|
+
Dash::Timings.current_entry = entry if defined?(Dash::Timings)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
9
30
|
class SSHKit::Backend::Abstract
|
|
10
31
|
def capture_with_info(*args, **kwargs)
|
|
11
32
|
capture(*args, **kwargs, verbosity: Logger::INFO)
|
|
@@ -64,6 +85,22 @@ class SSHKit::Backend::Abstract
|
|
|
64
85
|
end
|
|
65
86
|
end
|
|
66
87
|
prepend CommandEnvMerge
|
|
88
|
+
|
|
89
|
+
# Attributes the wall time of every command to the timing entry that is current on
|
|
90
|
+
# this thread, so a phase can report how much of its total was spent waiting on round
|
|
91
|
+
# trips rather than on the app. Nothing is executed that would not have run anyway —
|
|
92
|
+
# this only stamps the commands dash was already issuing.
|
|
93
|
+
module TimedCommands
|
|
94
|
+
private
|
|
95
|
+
def create_command_and_execute(args, options)
|
|
96
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
97
|
+
|
|
98
|
+
super
|
|
99
|
+
ensure
|
|
100
|
+
DashTimings.timings&.attribute_command(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, local: !!host&.local?)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
prepend TimedCommands
|
|
67
104
|
end
|
|
68
105
|
|
|
69
106
|
class SSHKit::Backend::Netssh::Configuration
|
|
@@ -171,6 +208,21 @@ class SSHKit::Backend::Netssh
|
|
|
171
208
|
end
|
|
172
209
|
prepend LimitConcurrentStartsInstance
|
|
173
210
|
|
|
211
|
+
# Prepended last, so it is in front of the concurrency limiter and the DNS retries:
|
|
212
|
+
# what a phase pays for a connection includes queueing behind max_concurrent_starts.
|
|
213
|
+
# The pool only calls through on a cache miss, so this measures real connects.
|
|
214
|
+
module TimedConnects
|
|
215
|
+
private
|
|
216
|
+
def connect_ssh(...)
|
|
217
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
218
|
+
|
|
219
|
+
super
|
|
220
|
+
ensure
|
|
221
|
+
DashTimings.timings&.attribute_connect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
prepend TimedConnects
|
|
225
|
+
|
|
174
226
|
# A pooled session that sat idle while dash was busy elsewhere (a server-lock
|
|
175
227
|
# wait, a loadbalancer reboot) gets dropped by NATs and cloud networks without
|
|
176
228
|
# either end noticing: net-ssh only sends keepalives from inside its event
|
|
@@ -181,8 +233,15 @@ class SSHKit::Backend::Netssh
|
|
|
181
233
|
# The block is rerun, so a drop *mid-command* runs the command twice. That
|
|
182
234
|
# is accepted: the errors here are the idle-drop ones, where nothing reached
|
|
183
235
|
# the server, and the alternative is the deploy failing on the next host.
|
|
236
|
+
#
|
|
237
|
+
# Net::SSH::Timeout (a Disconnect subclass) is deliberately excluded. net-ssh
|
|
238
|
+
# raises it when a host has ignored `keepalive_maxcount` keepalives in a row,
|
|
239
|
+
# which is a host that stopped answering mid-command, not a dropped idle
|
|
240
|
+
# socket. Retrying that reconnects to a host that has just proven unresponsive
|
|
241
|
+
# and, if it accepts the connection but never finishes the handshake or the
|
|
242
|
+
# command, hangs the deploy with no further guard. Let it fail.
|
|
184
243
|
module ReconnectOnStaleConnection
|
|
185
|
-
STALE_CONNECTION_ERRORS = [ Errno::ECONNRESET, Errno::EPIPE, Net::SSH::Disconnect
|
|
244
|
+
STALE_CONNECTION_ERRORS = [ Errno::ECONNRESET, Errno::EPIPE, Net::SSH::Disconnect ].freeze
|
|
186
245
|
|
|
187
246
|
private
|
|
188
247
|
def with_ssh
|
|
@@ -191,12 +250,12 @@ class SSHKit::Backend::Netssh
|
|
|
191
250
|
begin
|
|
192
251
|
super do |ssh|
|
|
193
252
|
yield ssh
|
|
194
|
-
rescue *STALE_CONNECTION_ERRORS
|
|
195
|
-
evict_stale_session(ssh)
|
|
253
|
+
rescue *STALE_CONNECTION_ERRORS => e
|
|
254
|
+
evict_stale_session(ssh) if stale_connection_error?(e)
|
|
196
255
|
raise
|
|
197
256
|
end
|
|
198
257
|
rescue *STALE_CONNECTION_ERRORS => e
|
|
199
|
-
raise if reconnected
|
|
258
|
+
raise if reconnected || !stale_connection_error?(e)
|
|
200
259
|
|
|
201
260
|
reconnected = true
|
|
202
261
|
SSHKit.config.output.warn("Reconnecting to #{host}: #{e.message}")
|
|
@@ -204,6 +263,10 @@ class SSHKit::Backend::Netssh
|
|
|
204
263
|
end
|
|
205
264
|
end
|
|
206
265
|
|
|
266
|
+
def stale_connection_error?(error)
|
|
267
|
+
!error.is_a?(Net::SSH::Timeout)
|
|
268
|
+
end
|
|
269
|
+
|
|
207
270
|
# `close` waits for channel-close acknowledgements, which never come over
|
|
208
271
|
# a dead socket. `shutdown!` just closes the socket, and a closed session
|
|
209
272
|
# is what makes the pool drop it instead of caching it again.
|
|
@@ -224,9 +287,15 @@ class SSHKit::Runner::Parallel
|
|
|
224
287
|
# problem occurs on multiple hosts.
|
|
225
288
|
module CompleteAll
|
|
226
289
|
def execute
|
|
290
|
+
# A new thread starts with none of its parent's thread-locals, so the timing entry
|
|
291
|
+
# has to be handed over explicitly or every command run on a host would be
|
|
292
|
+
# attributed to no phase at all.
|
|
293
|
+
timing_entry = DashTimings.current_entry
|
|
294
|
+
|
|
227
295
|
threads = hosts.map do |host|
|
|
228
296
|
Thread.new(host) do |h|
|
|
229
297
|
Thread.current.report_on_exception = false
|
|
298
|
+
DashTimings.current_entry = timing_entry
|
|
230
299
|
backend(h, &block).run
|
|
231
300
|
rescue ::StandardError => e
|
|
232
301
|
e2 = SSHKit::Runner::ExecuteError.new e
|
|
@@ -304,9 +373,13 @@ module SSHKitDslRoles
|
|
|
304
373
|
# end
|
|
305
374
|
def on_roles(roles, hosts:, parallel: true, rolling: false, &block)
|
|
306
375
|
if parallel
|
|
376
|
+
# See CompleteAll#execute: thread-locals do not cross Thread.new.
|
|
377
|
+
timing_entry = DashTimings.current_entry
|
|
378
|
+
|
|
307
379
|
threads = roles.filter_map do |role|
|
|
308
380
|
if (role_hosts = role.hosts & hosts).any?
|
|
309
381
|
Thread.new do
|
|
382
|
+
DashTimings.current_entry = timing_entry
|
|
310
383
|
on(role_hosts, rolling ? role.boot_runner_options(role_hosts) : {}) { |host| instance_exec(host, role, &block) }
|
|
311
384
|
rescue StandardError => e
|
|
312
385
|
raise SSHKit::Runner::ExecuteError.new(e), "Exception while executing on #{role}: #{e.message}"
|