dash 4.0.8 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/dash/build/progress_parser.rb +136 -0
- data/lib/dash/build/report.rb +104 -0
- data/lib/dash/build/step.rb +49 -0
- data/lib/dash/cli/app/boot.rb +46 -24
- data/lib/dash/cli/app.rb +4 -4
- data/lib/dash/cli/base.rb +117 -2
- data/lib/dash/cli/build.rb +83 -20
- data/lib/dash/cli/doctor/config_checks.rb +36 -1
- data/lib/dash/cli/doctor.rb +2 -1
- data/lib/dash/cli/healthcheck/poller.rb +15 -3
- data/lib/dash/cli/healthcheck/progress_reporter.rb +39 -0
- data/lib/dash/cli/main.rb +24 -7
- data/lib/dash/cli/proxy/drift.rb +17 -2
- data/lib/dash/cli/proxy/legacy_rename.rb +8 -21
- data/lib/dash/cli/proxy/loadbalancer_reboot.rb +8 -1
- data/lib/dash/cli/proxy/reboot.rb +6 -1
- data/lib/dash/cli/proxy.rb +19 -12
- data/lib/dash/cli/prune.rb +5 -8
- data/lib/dash/cli/report.rb +97 -0
- data/lib/dash/cli/templates/sample_hooks/post-deploy.sample +5 -0
- data/lib/dash/commander.rb +10 -2
- data/lib/dash/commands/app.rb +81 -0
- data/lib/dash/commands/auditor.rb +10 -0
- data/lib/dash/commands/base.rb +42 -1
- data/lib/dash/commands/builder/base.rb +18 -0
- data/lib/dash/commands/builder.rb +1 -1
- data/lib/dash/commands/loadbalancer.rb +54 -0
- data/lib/dash/commands/proxy/state.rb +31 -0
- data/lib/dash/commands/proxy.rb +74 -2
- data/lib/dash/commands/registry.rb +14 -0
- data/lib/dash/configuration/docs/configuration.yml +6 -0
- data/lib/dash/configuration/docs/report.yml +39 -0
- data/lib/dash/configuration/docs/role.yml +7 -7
- data/lib/dash/configuration/proxy.rb +3 -0
- data/lib/dash/configuration/report.rb +65 -0
- data/lib/dash/configuration.rb +2 -1
- data/lib/dash/dockerfile/analyzer.rb +66 -0
- data/lib/dash/dockerfile/context.rb +147 -0
- data/lib/dash/dockerfile/dockerignore.rb +29 -0
- data/lib/dash/dockerfile/document.rb +28 -0
- data/lib/dash/dockerfile/finding.rb +20 -0
- data/lib/dash/dockerfile/hadolint.rb +75 -0
- data/lib/dash/dockerfile/instruction.rb +58 -0
- data/lib/dash/dockerfile/parser.rb +199 -0
- data/lib/dash/dockerfile/rules/apt_hygiene.rb +35 -0
- data/lib/dash/dockerfile/rules/base.rb +44 -0
- data/lib/dash/dockerfile/rules/cache_busting_arg.rb +40 -0
- data/lib/dash/dockerfile/rules/cache_export_cost.rb +20 -0
- data/lib/dash/dockerfile/rules/context_size.rb +20 -0
- data/lib/dash/dockerfile/rules/copy_before_install.rb +34 -0
- data/lib/dash/dockerfile/rules/curl_pipe_shell.rb +16 -0
- data/lib/dash/dockerfile/rules/dockerignore_gaps.rb +36 -0
- data/lib/dash/dockerfile/rules/inline_env_blob.rb +23 -0
- data/lib/dash/dockerfile/rules/latest_base.rb +24 -0
- data/lib/dash/dockerfile/rules/missing_dockerignore.rb +11 -0
- data/lib/dash/dockerfile/rules/no_cache_mount.rb +26 -0
- data/lib/dash/dockerfile/rules/root_user.rb +12 -0
- data/lib/dash/dockerfile/rules/secret_in_build_arg.rb +30 -0
- data/lib/dash/dockerfile/rules/single_stage_build_deps.rb +19 -0
- data/lib/dash/dockerfile/rules/uncached_install.rb +20 -0
- data/lib/dash/dockerfile/stage.rb +65 -0
- data/lib/dash/otel_shipper.rb +5 -4
- data/lib/dash/output/otel_logger.rb +52 -0
- data/lib/dash/report/history.rb +94 -0
- data/lib/dash/report/trends.rb +129 -0
- data/lib/dash/report/writer.rb +142 -0
- data/lib/dash/report.rb +170 -0
- data/lib/dash/sshkit_with_ext.rb +62 -0
- data/lib/dash/timings.rb +163 -10
- data/lib/dash/utils.rb +7 -0
- data/lib/dash/version.rb +1 -1
- data/lib/dash.rb +4 -0
- metadata +38 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# Turns Dockerfile text into instructions and stages.
|
|
2
|
+
#
|
|
3
|
+
# Line-oriented, because that is how BuildKit reads it: a physical line is joined to the
|
|
4
|
+
# next while it ends in the escape character, comment lines in between are dropped, and a
|
|
5
|
+
# heredoc redirection pulls the following lines in verbatim until its delimiter.
|
|
6
|
+
#
|
|
7
|
+
# It is deliberately forgiving. Advice is a courtesy printed next to a deploy, so a file
|
|
8
|
+
# this parser cannot make sense of must produce fewer findings, never an exception — the
|
|
9
|
+
# authority on whether a Dockerfile builds is BuildKit, not this.
|
|
10
|
+
class Dash::Dockerfile::Parser
|
|
11
|
+
DIRECTIVE = /\A#\s*(?<name>syntax|escape)\s*=\s*(?<value>\S+)\s*\z/i
|
|
12
|
+
COMMENT = /\A\s*#/
|
|
13
|
+
BLANK = /\A\s*\z/
|
|
14
|
+
INSTRUCTION = /\A\s*(?<name>[A-Za-z]+)(?:\s+(?<rest>.*))?\z/m
|
|
15
|
+
FLAG = /\A--(?<key>[a-zA-Z][\w-]*)=(?<value>(?:"[^"]*"|'[^']*'|\S)*)\s*/
|
|
16
|
+
# `<<EOF`, `<<-EOF`, `<<"EOF"`, `<<'EOF'` — the quoted forms only change how BuildKit
|
|
17
|
+
# expands the body, not where it ends.
|
|
18
|
+
HEREDOC = /<<-?\s*(?<quote>["']?)(?<delimiter>[A-Za-z_]\w*)\k<quote>/
|
|
19
|
+
STAGE_NAME = /\A(?<base>\S+)(?:\s+AS\s+(?<name>\S+))?\z/i
|
|
20
|
+
|
|
21
|
+
def self.parse(text)
|
|
22
|
+
new(text).parse
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(text)
|
|
26
|
+
@lines = text.to_s.lines.map(&:chomp)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def parse
|
|
30
|
+
directives = parse_directives
|
|
31
|
+
escape = directives["escape"] == "`" ? "`" : "\\"
|
|
32
|
+
|
|
33
|
+
instructions = parse_instructions(escape)
|
|
34
|
+
stages = build_stages(instructions)
|
|
35
|
+
|
|
36
|
+
Dash::Dockerfile::Document.new(instructions: instructions, stages: stages, directives: directives)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
attr_reader :lines
|
|
41
|
+
|
|
42
|
+
# Only the comment block at the very top can carry directives; after the first
|
|
43
|
+
# instruction a `# syntax=` line is an ordinary comment.
|
|
44
|
+
def parse_directives
|
|
45
|
+
directives = {}
|
|
46
|
+
|
|
47
|
+
lines.each do |line|
|
|
48
|
+
break unless line.match?(COMMENT) || line.match?(BLANK)
|
|
49
|
+
next unless (match = line.match(DIRECTIVE))
|
|
50
|
+
|
|
51
|
+
directives[match[:name].downcase] = match[:value]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
directives
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse_instructions(escape)
|
|
58
|
+
instructions = []
|
|
59
|
+
index = 0
|
|
60
|
+
|
|
61
|
+
while index < lines.size
|
|
62
|
+
line = lines[index]
|
|
63
|
+
|
|
64
|
+
if line.match?(BLANK) || line.match?(COMMENT)
|
|
65
|
+
index += 1
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
started_at = index
|
|
70
|
+
text, index = join_continuations(index, escape)
|
|
71
|
+
text, index = append_heredocs(text, index)
|
|
72
|
+
|
|
73
|
+
instruction = build_instruction(text, started_at + 1)
|
|
74
|
+
instructions << instruction if instruction
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
instructions
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Joins physical lines while each ends in the escape character. Comment lines between
|
|
81
|
+
# them are BuildKit's own convention for annotating a long RUN, and are not part of
|
|
82
|
+
# the command.
|
|
83
|
+
def join_continuations(index, escape)
|
|
84
|
+
parts = []
|
|
85
|
+
|
|
86
|
+
while index < lines.size
|
|
87
|
+
line = lines[index]
|
|
88
|
+
index += 1
|
|
89
|
+
|
|
90
|
+
next if line.match?(COMMENT) && parts.any?
|
|
91
|
+
|
|
92
|
+
continues = line.rstrip.end_with?(escape)
|
|
93
|
+
parts << (continues ? line.rstrip.delete_suffix(escape) : line)
|
|
94
|
+
break unless continues
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
[ parts.map(&:strip).reject(&:empty?).join(" "), index ]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Every heredoc opened on the instruction line consumes lines until its delimiter, in
|
|
101
|
+
# the order they were opened. The bodies are appended to the arguments so rules can
|
|
102
|
+
# match what the instruction actually runs.
|
|
103
|
+
#
|
|
104
|
+
# A delimiter that never arrives means this was not a heredoc after all (`'<<EOF'` as
|
|
105
|
+
# a quoted shell word, or a typo): nothing is consumed, so the rest of the file is
|
|
106
|
+
# still parsed rather than folded into this one instruction.
|
|
107
|
+
def append_heredocs(text, index)
|
|
108
|
+
delimiters = text.scan(HEREDOC).map(&:last)
|
|
109
|
+
return [ text, index ] if delimiters.empty?
|
|
110
|
+
|
|
111
|
+
body = []
|
|
112
|
+
at = index
|
|
113
|
+
|
|
114
|
+
delimiters.each do |delimiter|
|
|
115
|
+
terminator = (at...lines.size).find { |line| lines[line].strip == delimiter }
|
|
116
|
+
return [ text, index ] unless terminator
|
|
117
|
+
|
|
118
|
+
body.concat heredoc_commands(lines[at...terminator])
|
|
119
|
+
at = terminator + 1
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Line breaks are kept: in a RUN heredoc each line is its own shell command, and the
|
|
123
|
+
# apt rules need to know where one ends.
|
|
124
|
+
[ [ text, *body ].join("\n"), at ]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Inside the body the shell's own continuation applies: a line ending in `\` is the
|
|
128
|
+
# same command as the next one.
|
|
129
|
+
def heredoc_commands(body)
|
|
130
|
+
body.map(&:strip).each_with_object([]) do |line, commands|
|
|
131
|
+
if commands.last&.end_with?("\\")
|
|
132
|
+
commands[-1] = "#{commands.last.delete_suffix("\\").rstrip} #{line}"
|
|
133
|
+
else
|
|
134
|
+
commands << line
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def build_instruction(text, line)
|
|
140
|
+
match = text.match(INSTRUCTION)
|
|
141
|
+
return unless match
|
|
142
|
+
|
|
143
|
+
flags, args = extract_flags(match[:rest].to_s.strip)
|
|
144
|
+
|
|
145
|
+
Dash::Dockerfile::Instruction.new(name: match[:name].upcase, args: args, flags: flags, line: line)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def extract_flags(rest)
|
|
149
|
+
flags = {}
|
|
150
|
+
|
|
151
|
+
while (match = rest.match(FLAG))
|
|
152
|
+
(flags[match[:key]] ||= []) << match[:value].delete_prefix('"').delete_suffix('"')
|
|
153
|
+
rest = match.post_match.lstrip
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
[ flags, rest ]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def build_stages(instructions)
|
|
160
|
+
stages = []
|
|
161
|
+
|
|
162
|
+
instructions.each do |instruction|
|
|
163
|
+
if instruction.name == "FROM"
|
|
164
|
+
stages << new_stage(instruction, stages.size)
|
|
165
|
+
elsif (stage = stages.last)
|
|
166
|
+
stage.instructions << instruction
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
instruction.stage = stages.last unless stages.empty? && instruction.name != "FROM"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
mark_shipped stages
|
|
173
|
+
stages
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def new_stage(instruction, index)
|
|
177
|
+
match = instruction.args.match(STAGE_NAME)
|
|
178
|
+
|
|
179
|
+
Dash::Dockerfile::Stage.new \
|
|
180
|
+
name: match && match[:name] || "stage-#{index}",
|
|
181
|
+
named: !(match && match[:name]).nil?,
|
|
182
|
+
index: index,
|
|
183
|
+
base: (match ? match[:base] : instruction.args),
|
|
184
|
+
from: instruction
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Walk back from the final stage through the bases it inherits. A stage reached only
|
|
188
|
+
# by `COPY --from=` is not on that chain, which is the point. Only an explicit `AS`
|
|
189
|
+
# name can be inherited from; the generated `stage-N` labels are dash's, not BuildKit's.
|
|
190
|
+
def mark_shipped(stages)
|
|
191
|
+
by_name = stages.select(&:named?).to_h { |stage| [ stage.name, stage ] }
|
|
192
|
+
stage = stages.last
|
|
193
|
+
|
|
194
|
+
while stage && !stage.shipped?
|
|
195
|
+
stage.shipped = true
|
|
196
|
+
stage = by_name[stage.base]
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# apt's recommended packages and its package lists both ship in the layer unless the same
|
|
2
|
+
# RUN gets rid of them. Only worth saying about a stage that ends up in the image.
|
|
3
|
+
class Dash::Dockerfile::Rules::AptHygiene < Dash::Dockerfile::Rules::Base
|
|
4
|
+
NO_RECOMMENDS = /--no-install-recommends/
|
|
5
|
+
LIST_CLEANUP = %r{rm\s+-rf\s+/var/lib/apt/lists}
|
|
6
|
+
APT_OPERATION = /\bapt-get\s+#{Dash::Dockerfile::Context::APT_OPTIONS.source}(?:update|install|upgrade)\b/
|
|
7
|
+
# One shell command at a time: a later, compliant install must not vouch for an
|
|
8
|
+
# earlier one, and a cleanup only counts after the last thing that refilled the lists.
|
|
9
|
+
# A heredoc body keeps its line breaks, so each of its lines is a command too.
|
|
10
|
+
SEGMENT = /&&|\|\||;|\n/
|
|
11
|
+
SUGGESTION = "add --no-install-recommends to every install and rm -rf /var/lib/apt/lists/* at the end of the same RUN"
|
|
12
|
+
|
|
13
|
+
def findings
|
|
14
|
+
document.shipped_stages.flat_map { |stage| stage.instructions }.filter_map do |instruction|
|
|
15
|
+
next unless context.apt_install?(instruction)
|
|
16
|
+
|
|
17
|
+
problems = problems_in(instruction.shell_command.split(SEGMENT))
|
|
18
|
+
next if problems.empty?
|
|
19
|
+
|
|
20
|
+
note at(instruction), "apt-get install #{problems.join(" and ")}", SUGGESTION
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
def problems_in(segments)
|
|
26
|
+
installs = segments.select { |segment| segment.match?(Dash::Dockerfile::Context::APT_INSTALL.first) }
|
|
27
|
+
last_apt = segments.rindex { |segment| segment.match?(APT_OPERATION) } or return []
|
|
28
|
+
cleaned = segments.drop(last_apt + 1).any? { |segment| segment.match?(LIST_CLEANUP) }
|
|
29
|
+
|
|
30
|
+
problems = []
|
|
31
|
+
problems << "installs recommended packages" if installs.any? { |segment| !segment.match?(NO_RECOMMENDS) }
|
|
32
|
+
problems << "leaves /var/lib/apt/lists in the layer" unless cleaned
|
|
33
|
+
problems
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "active_support/core_ext/module/delegation"
|
|
2
|
+
require "active_support/core_ext/string/inflections"
|
|
3
|
+
|
|
4
|
+
# A rule looks at the analysis context and returns findings. Nothing else: no IO, no
|
|
5
|
+
# state, no ordering assumptions about the other rules.
|
|
6
|
+
#
|
|
7
|
+
# The id is derived from the class name and is a public string — operators put it in
|
|
8
|
+
# `report: ignore:`, so renaming a rule class renames a config value.
|
|
9
|
+
class Dash::Dockerfile::Rules::Base
|
|
10
|
+
attr_reader :context
|
|
11
|
+
delegate :document, :build, :builder, :context_dir, :dockerignore, to: :context
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def id
|
|
15
|
+
@id ||= name.demodulize.underscore.dasherize
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(context)
|
|
20
|
+
@context = context
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def findings
|
|
24
|
+
[]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
def warning(location, message, suggestion = nil)
|
|
29
|
+
finding :warn, location, message, suggestion
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def note(location, message, suggestion = nil)
|
|
33
|
+
finding :info, location, message, suggestion
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def finding(severity, location, message, suggestion)
|
|
37
|
+
Dash::Dockerfile::Finding.new \
|
|
38
|
+
rule: self.class.id, severity: severity, location: location, message: message, suggestion: suggestion
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def at(instruction)
|
|
42
|
+
context.location_for(instruction)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# An ARG that changes on every commit — a git SHA, a build timestamp — invalidates every
|
|
2
|
+
# layer from its first use onwards. Referenced after the dependency install it costs
|
|
3
|
+
# nothing; referenced before it, it costs the whole install.
|
|
4
|
+
class Dash::Dockerfile::Rules::CacheBustingArg < Dash::Dockerfile::Rules::Base
|
|
5
|
+
CACHE_BUSTING = /\A(?:.*_)?(?:GIT_SHA|COMMIT|SHA|BUILD_DATE|BUILD_TIME|BUILDTIME|VERSION)\z/i
|
|
6
|
+
# RUBY_VERSION, NODE_VERSION and friends name a toolchain and change once a quarter.
|
|
7
|
+
TOOLCHAIN = /_VERSION\z/i
|
|
8
|
+
SUGGESTION = "reference it after the dependency install, so a new commit does not invalidate it"
|
|
9
|
+
|
|
10
|
+
def findings
|
|
11
|
+
document.each_instruction("ARG").filter_map { |arg| finding_for(arg) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
def finding_for(arg)
|
|
16
|
+
name = arg.args.split("=").first.to_s
|
|
17
|
+
return unless name.match?(CACHE_BUSTING) && !name.match?(TOOLCHAIN)
|
|
18
|
+
|
|
19
|
+
stage = arg.stage || document.stages.first or return
|
|
20
|
+
barrier = last_install_line(stage) or return
|
|
21
|
+
reference = reference_before(name, arg.line, barrier)
|
|
22
|
+
return unless reference
|
|
23
|
+
|
|
24
|
+
note at(arg), "ARG #{name} changes on every commit and is referenced on line #{reference.line}, before the dependency install on line #{barrier}", SUGGESTION
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def last_install_line(stage)
|
|
28
|
+
stage.instructions.select { |instruction| context.dependency_install?(instruction) }.last&.line
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A FROM that uses the ARG is pinning its base with it, which is a different thing
|
|
32
|
+
# and not something "reference it later" could ever fix.
|
|
33
|
+
def reference_before(name, from_line, barrier)
|
|
34
|
+
pattern = /\$(?:\{#{Regexp.escape(name)}\}|#{Regexp.escape(name)}(?!\w))/
|
|
35
|
+
|
|
36
|
+
document.instructions.find do |instruction|
|
|
37
|
+
instruction.name != "FROM" && instruction.line > from_line && instruction.line < barrier && instruction.to_s.match?(pattern)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Measured only: mode=max exports every intermediate layer to the cache. On a persistent
|
|
2
|
+
# builder that is often pure overhead — but only the numbers from this build can say so.
|
|
3
|
+
class Dash::Dockerfile::Rules::CacheExportCost < Dash::Dockerfile::Rules::Base
|
|
4
|
+
SHARE = 0.2
|
|
5
|
+
SUGGESTION = "try mode=min under builder: cache: options:"
|
|
6
|
+
|
|
7
|
+
def findings
|
|
8
|
+
return [] unless build && mode_max?
|
|
9
|
+
|
|
10
|
+
exported, total = build.cache_export_seconds, build.total_step_seconds
|
|
11
|
+
return [] if total.zero? || exported <= total * SHARE
|
|
12
|
+
|
|
13
|
+
[ note("builder.cache", format("exporting the build cache took %.1fs of %.1fs with mode=max", exported, total), SUGGESTION) ]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
def mode_max?
|
|
18
|
+
builder&.cache_to.to_s.split(",").map(&:strip).include?("mode=max")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Measured only: how many megabytes this build actually shipped to the builder before it
|
|
2
|
+
# ran a single instruction.
|
|
3
|
+
class Dash::Dockerfile::Rules::ContextSize < Dash::Dockerfile::Rules::Base
|
|
4
|
+
THRESHOLD_BYTES = 50_000_000
|
|
5
|
+
SUGGESTION = "trim the build context with .dockerignore, or point builder: context: at a smaller directory"
|
|
6
|
+
|
|
7
|
+
def findings
|
|
8
|
+
bytes = build&.context_bytes
|
|
9
|
+
return [] if bytes.to_i <= THRESHOLD_BYTES
|
|
10
|
+
|
|
11
|
+
[ warning("build context", "the build shipped #{Dash::Utils.human_bytes(bytes)}#{elapsed} of build context to the builder", SUGGESTION) ]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
# A build killed mid-transfer has a size but no duration; "in 0.0s" would be a
|
|
16
|
+
# measurement nobody took.
|
|
17
|
+
def elapsed
|
|
18
|
+
format(" in %.1fs", build.context_seconds) if build.context_seconds
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# The single most expensive Dockerfile mistake: copying the whole tree before installing
|
|
2
|
+
# dependencies, so every commit — a README typo included — reinstalls them.
|
|
3
|
+
class Dash::Dockerfile::Rules::CopyBeforeInstall < Dash::Dockerfile::Rules::Base
|
|
4
|
+
SUGGESTION = "copy the dependency manifests first (Gemfile, package.json, lockfiles), install, then copy the rest of the tree"
|
|
5
|
+
|
|
6
|
+
def findings
|
|
7
|
+
document.stages.filter_map { |stage| finding_for(stage) }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
def finding_for(stage)
|
|
12
|
+
copy = stage.instructions.find { |instruction| context.broad_copy?(instruction) } or return
|
|
13
|
+
install = stage.instructions.find do |instruction|
|
|
14
|
+
instruction.line > copy.line && context.dependency_install?(instruction)
|
|
15
|
+
end
|
|
16
|
+
return unless install
|
|
17
|
+
|
|
18
|
+
warning at(copy), message(copy, install), SUGGESTION
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def message(copy, install)
|
|
22
|
+
[ "#{copy.name} #{copy.args} runs before `#{context.install_command(install)}` (line #{install.line}), ",
|
|
23
|
+
"so dependencies reinstall on every commit", measured(install) ].compact.join
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Only when the build that just ran proves it: a cached install step costs nothing,
|
|
27
|
+
# and advice that names a number nobody measured is worse than advice that doesn't.
|
|
28
|
+
def measured(install)
|
|
29
|
+
step = context.build_step_for(install)
|
|
30
|
+
return if step.nil? || step.cached || step.seconds.to_f.zero?
|
|
31
|
+
|
|
32
|
+
format(" (measured %.1fs uncached)", step.seconds)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Piping a download straight into a shell runs whatever the server sends today, and there
|
|
2
|
+
# is no version in the Dockerfile to say what that was.
|
|
3
|
+
class Dash::Dockerfile::Rules::CurlPipeShell < Dash::Dockerfile::Rules::Base
|
|
4
|
+
# `sudo` may carry flags with or without arguments (`-E`, `-u root`) before the shell.
|
|
5
|
+
SHELL = /(?:sudo\s+(?:-\S+(?:\s+[^-\s]\S*)?\s+)*)?(?:ba|z|k)?sh\b/
|
|
6
|
+
PIPE_TO_SHELL = /\b(?:curl|wget)\b[^|]*\|\s*#{SHELL.source}|#{SHELL.source}\s+<\(\s*(?:curl|wget)\b/
|
|
7
|
+
SUGGESTION = "download to a file, verify a checksum, then run it — and pin the version"
|
|
8
|
+
|
|
9
|
+
def findings
|
|
10
|
+
document.instructions.filter_map do |instruction|
|
|
11
|
+
next unless instruction.name == "RUN" && instruction.shell_command.match?(PIPE_TO_SHELL)
|
|
12
|
+
|
|
13
|
+
note at(instruction), "a download is piped straight into a shell, so the build runs unverified code", SUGGESTION
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# A .dockerignore that misses the obvious offenders. .git is always worth naming; the
|
|
2
|
+
# rest only when the directory actually exists in the context.
|
|
3
|
+
class Dash::Dockerfile::Rules::DockerignoreGaps < Dash::Dockerfile::Rules::Base
|
|
4
|
+
ALWAYS = ".git".freeze
|
|
5
|
+
WHEN_PRESENT = %w[ node_modules tmp storage coverage log .env* ].freeze
|
|
6
|
+
SUGGESTION = "add them to .dockerignore so they are not sent to the builder"
|
|
7
|
+
|
|
8
|
+
def findings
|
|
9
|
+
return [] if dockerignore.nil?
|
|
10
|
+
return [] if (gaps = gaps()).empty?
|
|
11
|
+
|
|
12
|
+
[ finding(severity, Dash::Dockerfile::Dockerignore::FILENAME, message(gaps), SUGGESTION) ]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
def gaps
|
|
17
|
+
candidates = [ ALWAYS, *WHEN_PRESENT.flat_map { |name| context.context_entries(name) } ]
|
|
18
|
+
candidates.uniq.reject { |name| dockerignore.covers?(name) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# A context big enough to be worth seconds of every build turns the same gap from a
|
|
22
|
+
# tidiness note into something that is costing the operator time.
|
|
23
|
+
def severity
|
|
24
|
+
big_context? ? :warn : :info
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def message(gaps)
|
|
28
|
+
measured = " — the measured build context was #{Dash::Utils.human_bytes(build.context_bytes)}" if big_context?
|
|
29
|
+
|
|
30
|
+
"the build context ships #{gaps.join(", ")}#{measured}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def big_context?
|
|
34
|
+
build&.context_bytes.to_i > Dash::Dockerfile::Rules::ContextSize::THRESHOLD_BYTES
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# A wall of inline assignments in front of a command means editing any one value
|
|
2
|
+
# invalidates the layer — and makes the instruction unreadable in the bargain.
|
|
3
|
+
class Dash::Dockerfile::Rules::InlineEnvBlob < Dash::Dockerfile::Rules::Base
|
|
4
|
+
ASSIGNMENT = /\A[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\\.|\S)*\s+/
|
|
5
|
+
THRESHOLD = 20
|
|
6
|
+
SUGGESTION = "read them from an env file at runtime, or set the build-time ones in one ENV block, so the command line stops changing"
|
|
7
|
+
|
|
8
|
+
def findings
|
|
9
|
+
document.instructions.filter_map do |instruction|
|
|
10
|
+
next unless instruction.name == "RUN"
|
|
11
|
+
next unless (count = leading_assignments(instruction.shell_command.dup)) > THRESHOLD
|
|
12
|
+
|
|
13
|
+
note at(instruction), "#{count} inline environment assignments precede the command", SUGGESTION
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
def leading_assignments(command)
|
|
19
|
+
count = 0
|
|
20
|
+
count += 1 while command.sub!(ASSIGNMENT, "")
|
|
21
|
+
count
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# An unpinned base image makes a build unreproducible: the same Dockerfile builds a
|
|
2
|
+
# different image tomorrow, and nothing in the deploy says so.
|
|
3
|
+
class Dash::Dockerfile::Rules::LatestBase < Dash::Dockerfile::Rules::Base
|
|
4
|
+
SUGGESTION = "pin a specific tag, or a digest for a build that must be reproducible"
|
|
5
|
+
|
|
6
|
+
def findings
|
|
7
|
+
document.stages.filter_map { |stage| finding_for(stage) }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
def finding_for(stage)
|
|
12
|
+
return if built_on_another_stage?(stage) || stage.scratch? || stage.digest || stage.interpolated_tag?
|
|
13
|
+
|
|
14
|
+
if stage.tag.nil?
|
|
15
|
+
warning at(stage.instructions.first), "FROM #{stage.base} has no tag, so it resolves to :latest", SUGGESTION
|
|
16
|
+
elsif stage.tag == "latest"
|
|
17
|
+
warning at(stage.instructions.first), "FROM #{stage.base} is not pinned; the base image can change between builds", SUGGESTION
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def built_on_another_stage?(stage)
|
|
22
|
+
document.stages.any? { |other| other.named? && other.name == stage.base }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# No .dockerignore means the whole working tree is shipped to the builder: .git, test
|
|
2
|
+
# artifacts, node_modules, and whatever else happens to be lying around.
|
|
3
|
+
class Dash::Dockerfile::Rules::MissingDockerignore < Dash::Dockerfile::Rules::Base
|
|
4
|
+
SUGGESTION = "add a .dockerignore covering .git and any build or dependency directories"
|
|
5
|
+
|
|
6
|
+
def findings
|
|
7
|
+
return [] if context_dir.nil? || dockerignore
|
|
8
|
+
|
|
9
|
+
[ warning(Dash::Dockerfile::Dockerignore::FILENAME, "the build context has no .dockerignore, so everything in it is sent to the builder", SUGGESTION) ]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Without a cache mount, an install that misses the layer cache downloads every package
|
|
2
|
+
# again. With one, it reuses whatever the last build left behind.
|
|
3
|
+
class Dash::Dockerfile::Rules::NoCacheMount < Dash::Dockerfile::Rules::Base
|
|
4
|
+
def findings
|
|
5
|
+
document.instructions.filter_map do |instruction|
|
|
6
|
+
next unless installs?(instruction)
|
|
7
|
+
next if Array(instruction.flags["mount"]).any? { |mount| mount.include?("type=cache") }
|
|
8
|
+
|
|
9
|
+
note at(instruction), "#{command_for(instruction)} runs without a cache mount, so a cache miss downloads everything again",
|
|
10
|
+
"add --mount=type=cache,target=#{target_for(instruction)}"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
def installs?(instruction)
|
|
16
|
+
context.dependency_install?(instruction) || context.apt_install?(instruction)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def command_for(instruction)
|
|
20
|
+
context.install_command(instruction) || "apt-get install"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def target_for(instruction)
|
|
24
|
+
context.install_cache_target(instruction) || Dash::Dockerfile::Context::APT_INSTALL.last
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# A final stage that sets no USER runs as whatever its base image left it as — root, for
|
|
2
|
+
# nearly every official image — and so does anything that gets a shell in it.
|
|
3
|
+
class Dash::Dockerfile::Rules::RootUser < Dash::Dockerfile::Rules::Base
|
|
4
|
+
SUGGESTION = "create a non-root user and add a USER line before the entrypoint, unless the base image already switched"
|
|
5
|
+
|
|
6
|
+
def findings
|
|
7
|
+
stage = document.final_stage or return []
|
|
8
|
+
return [] if stage.instructions.any? { |instruction| instruction.name == "USER" }
|
|
9
|
+
|
|
10
|
+
[ note(at(stage.instructions.first), "the final stage sets no USER, so the container runs as whatever the base image does — usually root", SUGGESTION) ]
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Build args and ENV values are readable in the image history forever. A secret that has
|
|
2
|
+
# to be present at build time belongs in a mount that leaves no layer behind.
|
|
3
|
+
class Dash::Dockerfile::Rules::SecretInBuildArg < Dash::Dockerfile::Rules::Base
|
|
4
|
+
SECRETISH = /(PASSWORD|SECRET|TOKEN|_KEY)\b/i
|
|
5
|
+
# Rails' own placeholder: it exists precisely so no real key is needed at build time.
|
|
6
|
+
ALLOWED = %w[ SECRET_KEY_BASE_DUMMY ].freeze
|
|
7
|
+
SUGGESTION = "pass it with --mount=type=secret and list it under builder: secrets: in deploy.yml"
|
|
8
|
+
|
|
9
|
+
def findings
|
|
10
|
+
document.instructions.flat_map do |instruction|
|
|
11
|
+
next [] unless %w[ ARG ENV ].include?(instruction.name)
|
|
12
|
+
|
|
13
|
+
names(instruction).filter_map do |name|
|
|
14
|
+
next if ALLOWED.include?(name) || !name.match?(SECRETISH)
|
|
15
|
+
|
|
16
|
+
warning at(instruction), "#{instruction.name} #{name} bakes a secret into the image history", SUGGESTION
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
# `ENV KEY value` (legacy form) declares exactly one name, whatever the value holds;
|
|
23
|
+
# `ENV A=1 B=2` and `ARG NAME[=default]` declare one per assignment.
|
|
24
|
+
def names(instruction)
|
|
25
|
+
first = instruction.args.split(/\s+/).first.to_s
|
|
26
|
+
return [ first ] unless first.include?("=")
|
|
27
|
+
|
|
28
|
+
instruction.args.scan(/(?:\A|\s)([A-Za-z_]\w*)=/).flatten
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Compilers and header packages installed in the only stage ship to production: a bigger
|
|
2
|
+
# image, a bigger attack surface, and nothing gained once the gems are built.
|
|
3
|
+
class Dash::Dockerfile::Rules::SingleStageBuildDeps < Dash::Dockerfile::Rules::Base
|
|
4
|
+
# A lookahead for whitespace or a shell separator rather than `\b`: `+` is not a word
|
|
5
|
+
# character, so `g++\b` never matches.
|
|
6
|
+
BUILD_PACKAGES = /\b(build-essential|gcc|g\+\+|make|[\w.+-]+-dev)(?=[\s;&|)]|$)/
|
|
7
|
+
SUGGESTION = "split into a build stage and a runtime stage, and COPY --from the build output"
|
|
8
|
+
|
|
9
|
+
def findings
|
|
10
|
+
return [] unless document.stages.one?
|
|
11
|
+
|
|
12
|
+
document.stages.first.instructions.filter_map do |instruction|
|
|
13
|
+
next unless instruction.name == "RUN"
|
|
14
|
+
next unless (packages = instruction.shell_command.scan(BUILD_PACKAGES).map(&:first)).any?
|
|
15
|
+
|
|
16
|
+
warning at(instruction), "the only stage installs build tooling (#{packages.uniq.join(", ")}), which ships in the image", SUGGESTION
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Measured only: a dependency install that missed the cache and cost real time, where no
|
|
2
|
+
# broad copy above it explains why. Something else invalidated the layer, and the operator
|
|
3
|
+
# is the only one who can say what.
|
|
4
|
+
class Dash::Dockerfile::Rules::UncachedInstall < Dash::Dockerfile::Rules::Base
|
|
5
|
+
THRESHOLD_SECONDS = 10
|
|
6
|
+
SUGGESTION = "check what changed above it — a COPY, an ARG, or a base image that moved"
|
|
7
|
+
|
|
8
|
+
def findings
|
|
9
|
+
return [] unless build
|
|
10
|
+
|
|
11
|
+
document.instructions.filter_map do |instruction|
|
|
12
|
+
next unless context.dependency_install?(instruction) && !context.busted_by_broad_copy?(instruction)
|
|
13
|
+
|
|
14
|
+
step = context.build_step_for(instruction)
|
|
15
|
+
next if step.nil? || step.cached || step.seconds.to_f <= THRESHOLD_SECONDS
|
|
16
|
+
|
|
17
|
+
note at(instruction), format("`%s` ran %.1fs uncached", context.install_command(instruction), step.seconds), SUGGESTION
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|