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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dash/build/progress_parser.rb +136 -0
  3. data/lib/dash/build/report.rb +104 -0
  4. data/lib/dash/build/step.rb +49 -0
  5. data/lib/dash/cli/app/boot.rb +46 -24
  6. data/lib/dash/cli/app.rb +4 -4
  7. data/lib/dash/cli/base.rb +117 -2
  8. data/lib/dash/cli/build.rb +83 -20
  9. data/lib/dash/cli/doctor/config_checks.rb +36 -1
  10. data/lib/dash/cli/doctor.rb +2 -1
  11. data/lib/dash/cli/healthcheck/poller.rb +15 -3
  12. data/lib/dash/cli/healthcheck/progress_reporter.rb +39 -0
  13. data/lib/dash/cli/main.rb +24 -7
  14. data/lib/dash/cli/proxy/drift.rb +17 -2
  15. data/lib/dash/cli/proxy/legacy_rename.rb +8 -21
  16. data/lib/dash/cli/proxy/loadbalancer_reboot.rb +8 -1
  17. data/lib/dash/cli/proxy/reboot.rb +6 -1
  18. data/lib/dash/cli/proxy.rb +19 -12
  19. data/lib/dash/cli/prune.rb +5 -8
  20. data/lib/dash/cli/report.rb +97 -0
  21. data/lib/dash/cli/templates/sample_hooks/post-deploy.sample +5 -0
  22. data/lib/dash/commander.rb +10 -2
  23. data/lib/dash/commands/app.rb +81 -0
  24. data/lib/dash/commands/auditor.rb +10 -0
  25. data/lib/dash/commands/base.rb +42 -1
  26. data/lib/dash/commands/builder/base.rb +18 -0
  27. data/lib/dash/commands/builder.rb +1 -1
  28. data/lib/dash/commands/loadbalancer.rb +54 -0
  29. data/lib/dash/commands/proxy/state.rb +31 -0
  30. data/lib/dash/commands/proxy.rb +74 -2
  31. data/lib/dash/commands/registry.rb +14 -0
  32. data/lib/dash/configuration/docs/configuration.yml +6 -0
  33. data/lib/dash/configuration/docs/report.yml +39 -0
  34. data/lib/dash/configuration/docs/role.yml +7 -7
  35. data/lib/dash/configuration/proxy.rb +3 -0
  36. data/lib/dash/configuration/report.rb +65 -0
  37. data/lib/dash/configuration.rb +2 -1
  38. data/lib/dash/dockerfile/analyzer.rb +66 -0
  39. data/lib/dash/dockerfile/context.rb +147 -0
  40. data/lib/dash/dockerfile/dockerignore.rb +29 -0
  41. data/lib/dash/dockerfile/document.rb +28 -0
  42. data/lib/dash/dockerfile/finding.rb +20 -0
  43. data/lib/dash/dockerfile/hadolint.rb +75 -0
  44. data/lib/dash/dockerfile/instruction.rb +58 -0
  45. data/lib/dash/dockerfile/parser.rb +199 -0
  46. data/lib/dash/dockerfile/rules/apt_hygiene.rb +35 -0
  47. data/lib/dash/dockerfile/rules/base.rb +44 -0
  48. data/lib/dash/dockerfile/rules/cache_busting_arg.rb +40 -0
  49. data/lib/dash/dockerfile/rules/cache_export_cost.rb +20 -0
  50. data/lib/dash/dockerfile/rules/context_size.rb +20 -0
  51. data/lib/dash/dockerfile/rules/copy_before_install.rb +34 -0
  52. data/lib/dash/dockerfile/rules/curl_pipe_shell.rb +16 -0
  53. data/lib/dash/dockerfile/rules/dockerignore_gaps.rb +36 -0
  54. data/lib/dash/dockerfile/rules/inline_env_blob.rb +23 -0
  55. data/lib/dash/dockerfile/rules/latest_base.rb +24 -0
  56. data/lib/dash/dockerfile/rules/missing_dockerignore.rb +11 -0
  57. data/lib/dash/dockerfile/rules/no_cache_mount.rb +26 -0
  58. data/lib/dash/dockerfile/rules/root_user.rb +12 -0
  59. data/lib/dash/dockerfile/rules/secret_in_build_arg.rb +30 -0
  60. data/lib/dash/dockerfile/rules/single_stage_build_deps.rb +19 -0
  61. data/lib/dash/dockerfile/rules/uncached_install.rb +20 -0
  62. data/lib/dash/dockerfile/stage.rb +65 -0
  63. data/lib/dash/otel_shipper.rb +5 -4
  64. data/lib/dash/output/otel_logger.rb +52 -0
  65. data/lib/dash/report/history.rb +94 -0
  66. data/lib/dash/report/trends.rb +129 -0
  67. data/lib/dash/report/writer.rb +142 -0
  68. data/lib/dash/report.rb +170 -0
  69. data/lib/dash/sshkit_with_ext.rb +62 -0
  70. data/lib/dash/timings.rb +163 -10
  71. data/lib/dash/utils.rb +7 -0
  72. data/lib/dash/version.rb +1 -1
  73. data/lib/dash.rb +4 -0
  74. 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