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,39 @@
1
+ # Deploy report
2
+ #
3
+ # Every deploy, redeploy, setup, rollback and standalone build prints a report: a table of
4
+ # where the time went, the build steps underneath it, and advice about the Dockerfile and
5
+ # the build context. The `report` key controls the advice half of that; the table always
6
+ # prints.
7
+
8
+ # Report options
9
+ #
10
+ # The options are specified under the report key in the configuration file.
11
+ report:
12
+
13
+ # Advice
14
+ #
15
+ # Print the Advice block under the table. The measurements themselves are always
16
+ # collected — this only decides whether dash says anything about them.
17
+ advice: true
18
+
19
+ # hadolint
20
+ #
21
+ # Supplement dash's own rules with hadolint's when it is installed.
22
+ #
23
+ # `auto` runs it when `hadolint` is on PATH and stays silent when it is not; `false`
24
+ # never runs it. Any other value is a configuration error rather than a silent off.
25
+ # hadolint's exit status never affects the deploy, and its rule codes (DL3008 and
26
+ # friends) can be silenced through `ignore` like any other rule.
27
+ hadolint: auto
28
+
29
+ # History
30
+ #
31
+ # How many JSON reports to keep per destination under .dash/reports. Set to 0 to write
32
+ # none.
33
+ history: 20
34
+
35
+ # Ignore
36
+ #
37
+ # Rule ids to silence. The id is the string printed with each piece of advice.
38
+ ignore:
39
+ - root-user
@@ -107,22 +107,22 @@ servers:
107
107
  # A `healthcheck` cannot be combined with `health-*` keys under `options`.
108
108
  #
109
109
  # `exec` is the escape hatch for an image whose HEALTHCHECK you cannot change,
110
- # or for an emergency override without a rebuild. Kamal `docker exec`s it from
111
- # the deploy host on every poll and gates the deploy on the exit code — no HTTP
110
+ # or for an emergency override without a rebuild. Dash `docker exec`s it on the
111
+ # deploy host once a second and gates the deploy on the exit code — no HTTP
112
112
  # server and no published port needed, and unlike `cmd` it may use `${...}`,
113
113
  # which is quoted through to the container. It is strictly worse than `cmd` in
114
114
  # the general case, so reach for it only when `cmd` is not available:
115
115
  #
116
116
  # - deploy-time only. Docker never runs it, so `docker ps` never shows
117
117
  # `(healthy)` and `docker inspect` keeps no probe history.
118
- # - each poll costs an SSH round trip plus a process spawn (~100-300ms), which
119
- # rules out sub-second polling.
118
+ # - each attempt costs a process spawn on the host (the whole wait is one SSH
119
+ # round trip, so the cost does not grow with how long the boot takes).
120
120
  # - nothing outside a deploy ever runs it.
121
121
  #
122
122
  # `exec` replaces docker's healthcheck rather than configuring it, so it cannot
123
- # be combined with `cmd`, `port`, `path`, or any of the duration keys. Polling
124
- # follows the deploy's own backoff and gives up at `deploy_timeout`; a probe that
125
- # never exits zero fails the boot and leaves the old container running.
123
+ # be combined with `cmd`, `port`, `path`, or any of the duration keys. The wait
124
+ # gives up at `deploy_timeout`; a probe that never exits zero fails the boot and
125
+ # leaves the old container running.
126
126
  #
127
127
  # A non-proxied role with neither a `healthcheck` nor a `health-cmd` option
128
128
  # warns on every deploy, because the readiness delay is the only thing standing
@@ -23,6 +23,9 @@ class Dash::Configuration::Proxy
23
23
  LEGACY_LOADBALANCER_CONTAINER_NAME = "kamal-loadbalancer"
24
24
  LEGACY_HOLDER_CONTAINER_NAME = "kamal-proxy-net"
25
25
  LEGACY_NETWORK = "kamal"
26
+ # Written into the run directory once a host is verifiably past the 3c rename, so the
27
+ # bridge costs it nothing but a `test -f` on every deploy after. Deleted in stage 3d.
28
+ LEGACY_RENAME_MARKER = ".legacy-renamed"
26
29
  LEGACY_CONFIG_VOLUME = "kamal-proxy-config"
27
30
  LEGACY_LOADBALANCER_CONFIG_VOLUME = "kamal-loadbalancer-config"
28
31
  LEGACY_IMAGE_TITLE = "kamal-proxy"
@@ -0,0 +1,65 @@
1
+ # The `report:` block: how much of the deploy report to print, and how much of it to keep.
2
+ #
3
+ # Every key is optional and the defaults are what an operator who has never heard of the
4
+ # block gets — the table always prints, the advice under it prints, and hadolint joins in
5
+ # only if they already have it installed.
6
+ class Dash::Configuration::Report
7
+ include Dash::Configuration::Validation
8
+
9
+ DEFAULT_HISTORY = 20
10
+ HADOLINT_AUTO = "auto".freeze
11
+
12
+ attr_reader :report_config
13
+
14
+ HADOLINT_SETTINGS = [ HADOLINT_AUTO, true, false ].freeze
15
+
16
+ def initialize(config:)
17
+ @report_config = config.raw_config.report || {}
18
+ validate! @report_config unless @report_config.empty?
19
+ ensure_valid_hadolint_setting
20
+ ensure_valid_history
21
+ end
22
+
23
+ def advice?
24
+ report_config.fetch("advice", true)
25
+ end
26
+
27
+ def hadolint
28
+ report_config.fetch("hadolint", HADOLINT_AUTO)
29
+ end
30
+
31
+ # "auto" (or true) means run it when it is on PATH — the availability check itself
32
+ # lives in Dash::Dockerfile::Hadolint, because only it knows what running costs.
33
+ def hadolint?
34
+ hadolint != false
35
+ end
36
+
37
+ def history
38
+ report_config.fetch("history", DEFAULT_HISTORY).to_i
39
+ end
40
+
41
+ def ignore
42
+ Array(report_config["ignore"]).map(&:to_s)
43
+ end
44
+
45
+ def to_h
46
+ report_config
47
+ end
48
+
49
+ private
50
+ # A misspelling must not read as "off": the operator would lose findings and never
51
+ # learn why.
52
+ def ensure_valid_hadolint_setting
53
+ return if HADOLINT_SETTINGS.include?(hadolint)
54
+
55
+ raise Dash::ConfigurationError, "report/hadolint: must be auto or false, got #{hadolint.inspect}"
56
+ end
57
+
58
+ # Same reasoning: a negative count is a typo, and reading it as "keep none" would
59
+ # quietly stop saving the reports the operator was configuring.
60
+ def ensure_valid_history
61
+ return if history >= 0
62
+
63
+ raise Dash::ConfigurationError, "report/history: must be 0 or more, got #{report_config["history"].inspect}"
64
+ end
65
+ end
@@ -16,7 +16,7 @@ class Dash::Configuration
16
16
  delegate :argumentize, :optionize, to: Dash::Utils
17
17
 
18
18
  attr_reader :destination, :raw_config, :secrets
19
- attr_reader :accessories, :aliases, :boot, :builder, :env, :logging, :output, :proxy, :proxy_boot, :servers, :ssh, :sshkit, :registry
19
+ attr_reader :accessories, :aliases, :boot, :builder, :env, :logging, :output, :proxy, :proxy_boot, :report, :servers, :ssh, :sshkit, :registry
20
20
 
21
21
  include Validation
22
22
 
@@ -76,6 +76,7 @@ class Dash::Configuration
76
76
 
77
77
  @logging = Logging.new(logging_config: @raw_config.logging)
78
78
  @output = Output.new(config: self)
79
+ @report = Report.new(config: self)
79
80
  @proxy = Proxy.new(config: self, proxy_config: @raw_config.proxy, secrets: secrets)
80
81
  @proxy_boot = Proxy::Boot.new(config: self)
81
82
  @ssh = Ssh.new(config: self)
@@ -0,0 +1,66 @@
1
+ # Runs the rule set over a parsed Dockerfile and returns the advice.
2
+ #
3
+ # Rules that need measurements (`build:`) stay silent without them, so the same analyzer
4
+ # serves `dash doctor` — static, no build, no SSH — and the block printed under a deploy,
5
+ # where the numbers upgrade a static hint into "this cost you 84.1 seconds".
6
+ class Dash::Dockerfile::Analyzer
7
+ RULES = [
8
+ Dash::Dockerfile::Rules::CopyBeforeInstall,
9
+ Dash::Dockerfile::Rules::ContextSize,
10
+ Dash::Dockerfile::Rules::MissingDockerignore,
11
+ Dash::Dockerfile::Rules::DockerignoreGaps,
12
+ Dash::Dockerfile::Rules::LatestBase,
13
+ Dash::Dockerfile::Rules::SecretInBuildArg,
14
+ Dash::Dockerfile::Rules::SingleStageBuildDeps,
15
+ Dash::Dockerfile::Rules::AptHygiene,
16
+ Dash::Dockerfile::Rules::CacheBustingArg,
17
+ Dash::Dockerfile::Rules::CacheExportCost,
18
+ Dash::Dockerfile::Rules::CurlPipeShell,
19
+ Dash::Dockerfile::Rules::InlineEnvBlob,
20
+ Dash::Dockerfile::Rules::NoCacheMount,
21
+ Dash::Dockerfile::Rules::UncachedInstall,
22
+ Dash::Dockerfile::Rules::RootUser
23
+ ].freeze
24
+
25
+ # Reads and parses the file. Missing is the caller's problem to report: `dash doctor`
26
+ # fails the check, a deploy stays quiet (a --skip-push deploy has no Dockerfile and no
27
+ # business complaining about it).
28
+ def self.for_file(path, **options)
29
+ new document: Dash::Dockerfile::Parser.parse(File.read(path)), path: path, file: path, **options
30
+ end
31
+
32
+ # `path` is what findings print (the operator's own `builder: dockerfile:`); `file` is
33
+ # where the file actually is, for the one rule that has to open it again.
34
+ def initialize(document:, context_dir: nil, build: nil, builder: nil, path: "Dockerfile", file: path, ignore: [], hadolint: false)
35
+ # A context that is not a local directory (a git URL, or a clone that has not been
36
+ # prepared yet) is not something the .dockerignore rules can say anything about.
37
+ directory = context_dir if context_dir && File.directory?(context_dir)
38
+
39
+ @context = Dash::Dockerfile::Context.new \
40
+ document: document, context_dir: directory, dockerignore: (Dash::Dockerfile::Dockerignore.in(directory) if directory),
41
+ build: build, builder: builder, path: path
42
+ @file = file
43
+ @ignore = Array(ignore).map(&:to_s)
44
+ @hadolint = hadolint
45
+ end
46
+
47
+ # Warnings first, then the informational findings, each group in rule order — an
48
+ # operator reading the block top down sees what is costing them before what is merely
49
+ # worth knowing.
50
+ def findings
51
+ (rule_findings + hadolint_findings)
52
+ .reject { |finding| @ignore.include?(finding.rule) }
53
+ .sort_by.with_index { |finding, index| [ finding.warn? ? 0 : 1, index ] }
54
+ end
55
+
56
+ private
57
+ def rule_findings
58
+ RULES.flat_map { |rule| rule.new(@context).findings }
59
+ end
60
+
61
+ def hadolint_findings
62
+ return [] unless @hadolint
63
+
64
+ Dash::Dockerfile::Hadolint.new(path: @context.path, file: @file).findings
65
+ end
66
+ end
@@ -0,0 +1,147 @@
1
+ # Everything a rule is allowed to look at: the parsed Dockerfile, the build context on
2
+ # disk, the builder's configuration, and — when the advice is printed next to a build
3
+ # that actually ran — what that build measured.
4
+ #
5
+ # The shared predicates live here rather than in each rule so that "what counts as a
6
+ # dependency install" has one answer, and so the measured half of a rule can find the
7
+ # buildx vertex that corresponds to a Dockerfile line.
8
+ class Dash::Dockerfile::Context
9
+ # The package managers whose install step is worth protecting from cache busting, and
10
+ # the cache directory each conventionally wants mounted.
11
+ DEPENDENCY_INSTALLS = [
12
+ [ /\bbundle\s+(?:_[\d._]+_\s+)?install\b/, "/usr/local/bundle/cache" ],
13
+ [ /\bnpm\s+(?:ci|install)\b/, "/root/.npm" ],
14
+ [ /\byarn\s+install\b/, "/usr/local/share/.cache/yarn" ],
15
+ [ /\bpnpm\s+install\b/, "/root/.local/share/pnpm/store" ],
16
+ [ /\bbun\s+install\b/, "/root/.bun/install/cache" ],
17
+ [ /\bpip3?\s+install\b/, "/root/.cache/pip" ],
18
+ [ /\bpoetry\s+install\b/, "/root/.cache/pypoetry" ],
19
+ [ /\bgo\s+mod\s+download\b/, "/go/pkg/mod" ],
20
+ [ /\bcargo\s+(?:build|fetch)\b/, "/usr/local/cargo/registry" ],
21
+ [ /\bcomposer\s+install\b/, "/root/.composer/cache" ],
22
+ [ /\bmix\s+deps\.get\b/, "/root/.hex" ],
23
+ [ /\bdotnet\s+restore\b/, "/root/.nuget/packages" ]
24
+ ].freeze
25
+
26
+ # apt takes its options before or after the verb (`apt-get -y install`,
27
+ # `apt-get -t bookworm-backports install`), so the verb is found past any of them. A
28
+ # shell separator is never an option or its value, so `apt-get -y && install` is not
29
+ # an apt install.
30
+ APT_OPTIONS = /(?:-[^\s;&|]+(?:\s+[^-\s;&|][^\s;&|]*)?\s+)*/
31
+ APT_INSTALL = [ /\bapt-get\s+#{APT_OPTIONS.source}install\b/, "/var/cache/apt" ].freeze
32
+
33
+ # A copy that ships the whole tree, so every commit invalidates it and everything
34
+ # layered on top of it.
35
+ BROAD_SOURCES = [ ".", "./", "*", "/" ].freeze
36
+
37
+ # buildx expands ARG and ENV references in the vertex name it prints, so a step's text
38
+ # and the Dockerfile line it came from stop agreeing at the first ${…}. Match on the
39
+ # longest shared prefix instead, and require enough of it that two unrelated RUNs
40
+ # cannot be confused for each other.
41
+ MINIMUM_STEP_MATCH = 12
42
+
43
+ attr_reader :document, :context_dir, :dockerignore, :build, :builder, :path
44
+
45
+ def initialize(document:, context_dir: nil, dockerignore: nil, build: nil, builder: nil, path: "Dockerfile")
46
+ @document = document
47
+ @context_dir = context_dir
48
+ @dockerignore = dockerignore
49
+ @build = build
50
+ @builder = builder
51
+ @path = path
52
+ end
53
+
54
+ def location_for(instruction)
55
+ "#{path}:#{instruction.line}"
56
+ end
57
+
58
+ def dependency_install?(instruction)
59
+ !install_match(instruction).nil?
60
+ end
61
+
62
+ # The command that made it a dependency install ("bundle install"), for advice that
63
+ # names what the operator wrote rather than the whole RUN.
64
+ def install_command(instruction)
65
+ install_match(instruction)&.first
66
+ end
67
+
68
+ def install_cache_target(instruction)
69
+ install_match(instruction)&.last
70
+ end
71
+
72
+ def apt_install?(instruction)
73
+ instruction.name == "RUN" && instruction.shell_command.match?(APT_INSTALL.first)
74
+ end
75
+
76
+ def broad_copy?(instruction)
77
+ return false unless %w[ COPY ADD ].include?(instruction.name)
78
+ return false if instruction.flag?("from")
79
+
80
+ sources(instruction).any? { |source| BROAD_SOURCES.include?(source) }
81
+ end
82
+
83
+ # A dependency install directly after a broad copy is invalidated by every commit,
84
+ # which is a finding of its own — rules that would otherwise report the same slow step
85
+ # twice defer to it.
86
+ def busted_by_broad_copy?(instruction)
87
+ stage = instruction.stage or return false
88
+
89
+ stage.instructions.any? { |other| other.line < instruction.line && broad_copy?(other) }
90
+ end
91
+
92
+ # buildx labels a step with its stage name except in a single-stage build, where it
93
+ # prints none. A multi-platform build reports the same step once per platform; the
94
+ # slowest one is the number worth quoting.
95
+ def build_step_for(instruction)
96
+ return unless build
97
+
98
+ text = normalize(instruction.to_s)
99
+ candidates = build.instruction_steps.select { |step| same_stage?(step, instruction) }
100
+
101
+ best = candidates.max_by do |step|
102
+ matched = normalize(step.instruction)
103
+ [ matched == text ? 1 : 0, shared_prefix(text, matched), step.seconds.to_f ]
104
+ end
105
+ return unless best
106
+
107
+ matched = normalize(best.instruction)
108
+ best if matched == text || shared_prefix(text, matched) >= MINIMUM_STEP_MATCH
109
+ end
110
+
111
+ def context_entries(name)
112
+ return [] unless context_dir
113
+
114
+ Dir.glob(name, base: context_dir, flags: ::File::FNM_DOTMATCH)
115
+ end
116
+
117
+ private
118
+ def install_match(instruction)
119
+ return unless instruction.name == "RUN"
120
+
121
+ command = instruction.shell_command
122
+ DEPENDENCY_INSTALLS.each do |pattern, target|
123
+ matched = command[pattern]
124
+ return [ matched, target ] if matched
125
+ end
126
+ nil
127
+ end
128
+
129
+ def same_stage?(step, instruction)
130
+ step.stage.nil? ? document.stages.one? : step.stage == instruction.stage&.name
131
+ end
132
+
133
+ def sources(instruction)
134
+ words = instruction.json? ? instruction.argv : instruction.args.split(/\s+/)
135
+ words.size > 1 ? words[0..-2] : words
136
+ end
137
+
138
+ def normalize(text)
139
+ text.to_s.squeeze(" ").strip
140
+ end
141
+
142
+ def shared_prefix(one, other)
143
+ length = 0
144
+ length += 1 while length < one.length && length < other.length && one[length] == other[length]
145
+ length
146
+ end
147
+ end
@@ -0,0 +1,29 @@
1
+ # Just enough .dockerignore to answer "does this file ship in the build context?".
2
+ #
3
+ # Not a reimplementation of BuildKit's matcher: negations are skipped rather than
4
+ # applied, so a path this says is covered might still ship. That direction is the safe
5
+ # one — it costs a piece of advice, never a false accusation.
6
+ class Dash::Dockerfile::Dockerignore
7
+ FILENAME = ".dockerignore"
8
+
9
+ attr_reader :patterns
10
+
11
+ def self.in(directory)
12
+ path = ::File.join(directory.to_s, FILENAME)
13
+ new(::File.read(path).lines) if ::File.file?(path)
14
+ end
15
+
16
+ def initialize(lines)
17
+ @patterns = lines
18
+ .map(&:strip)
19
+ .reject { |line| line.empty? || line.start_with?("#", "!") }
20
+ .map { |line| line.delete_prefix("**/").delete_prefix("./").delete_prefix("/").delete_suffix("/") }
21
+ .reject(&:empty?)
22
+ end
23
+
24
+ def covers?(path)
25
+ patterns.any? do |pattern|
26
+ ::File.fnmatch?(pattern, path, ::File::FNM_DOTMATCH) || path.start_with?("#{pattern}/")
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # A parsed Dockerfile: its instructions in file order, grouped into stages.
2
+ #
3
+ # Named Document rather than File so that `File.fnmatch` inside Dash::Dockerfile still
4
+ # means the one in Ruby's core library.
5
+ class Dash::Dockerfile::Document
6
+ attr_reader :instructions, :stages, :directives
7
+
8
+ def initialize(instructions:, stages:, directives: {})
9
+ @instructions = instructions
10
+ @stages = stages
11
+ @directives = directives
12
+ end
13
+
14
+ def shipped_stages
15
+ stages.select(&:shipped?)
16
+ end
17
+
18
+ def final_stage
19
+ stages.last
20
+ end
21
+
22
+ def each_instruction(name)
23
+ return to_enum(:each_instruction, name) unless block_given?
24
+
25
+ name = name.to_s.upcase
26
+ instructions.each { |instruction| yield instruction if instruction.name == name }
27
+ end
28
+ end
@@ -0,0 +1,20 @@
1
+ # One piece of advice about the Dockerfile or the build context.
2
+ #
3
+ # `rule` is a stable public string an operator can put in `report: ignore:`, so renaming
4
+ # one is a breaking change to their deploy.yml. `location` is whatever they should open:
5
+ # a Dockerfile line, `.dockerignore`, `build context`, or a deploy.yml key.
6
+ Dash::Dockerfile::Finding = Struct.new(:rule, :severity, :location, :message, :suggestion, keyword_init: true) do
7
+ def warn?
8
+ severity == :warn
9
+ end
10
+
11
+ def self.from_h(hash)
12
+ hash = hash.transform_keys(&:to_sym)
13
+
14
+ new(**hash.slice(:rule, :location, :message, :suggestion), severity: hash[:severity].to_sym)
15
+ end
16
+
17
+ def to_h
18
+ { rule: rule, severity: severity.to_s, location: location, message: message, suggestion: suggestion }
19
+ end
20
+ end
@@ -0,0 +1,75 @@
1
+ require "json"
2
+ require "open3"
3
+ require "active_support/core_ext/string/filters"
4
+
5
+ # Optional supplement to the built-in rules: whatever `hadolint` has to say about the same
6
+ # file, when the operator already has it installed.
7
+ #
8
+ # Run as a plain local process rather than through SSHKit, so the command sequence a deploy
9
+ # prints — and the cost-guard test that pins it — is unchanged. `--no-fail` keeps its exit
10
+ # status out of the deploy, and anything that goes wrong becomes one informational finding
11
+ # rather than an exception.
12
+ class Dash::Dockerfile::Hadolint
13
+ EXECUTABLE = "hadolint".freeze
14
+ # hadolint's own levels. `error` is the only one worth a warning next to a deploy; the
15
+ # rest are style notes that should not compete with a measured finding.
16
+ SEVERITIES = { "error" => :warn }.freeze
17
+
18
+ class << self
19
+ # No shell: PATH is walked directly, so a directory with a space or a semicolon in it
20
+ # cannot turn a lookup into a command.
21
+ def available?
22
+ ENV["PATH"].to_s.split(::File::PATH_SEPARATOR).any? do |directory|
23
+ path = ::File.join(directory, EXECUTABLE)
24
+ ::File.executable?(path) && !::File.directory?(path)
25
+ end
26
+ end
27
+ end
28
+
29
+ def initialize(path:, file: path)
30
+ @path = path
31
+ @file = file
32
+ end
33
+
34
+ def findings
35
+ return [] unless self.class.available?
36
+
37
+ output, status = Open3.capture2(EXECUTABLE, "--format", "json", "--no-fail", @file)
38
+ return unavailable("exited #{status.exitstatus}") unless status.success?
39
+ return [] if output.strip.empty?
40
+
41
+ issues = JSON.parse(output)
42
+ return unparsable("expected a JSON array, got #{issues.class.name.downcase}") unless issues.is_a?(Array)
43
+
44
+ issues.map { |issue| finding_for(issue) }
45
+ # Only JSON.parse raises this, so it is the one error that means "it ran fine, dash
46
+ # could not read what it printed". Anything else that raises in here is dash's own.
47
+ rescue JSON::ParserError => e
48
+ unparsable(e.message.truncate(80))
49
+ rescue StandardError => e
50
+ unavailable(e.message)
51
+ end
52
+
53
+ private
54
+ def finding_for(issue)
55
+ Dash::Dockerfile::Finding.new \
56
+ rule: issue["code"],
57
+ severity: SEVERITIES.fetch(issue["level"], :info),
58
+ location: "#{@path}:#{issue["line"]}",
59
+ message: "#{issue["code"]}: #{issue["message"]}",
60
+ suggestion: nil
61
+ end
62
+
63
+ def unparsable(reason)
64
+ note "hadolint output could not be parsed (#{reason})"
65
+ end
66
+
67
+ def unavailable(reason)
68
+ note "hadolint could not run (#{reason})"
69
+ end
70
+
71
+ def note(message)
72
+ [ Dash::Dockerfile::Finding.new(rule: "hadolint", severity: :info, location: EXECUTABLE,
73
+ message: message, suggestion: "silence this with report: hadolint: false") ]
74
+ end
75
+ end
@@ -0,0 +1,58 @@
1
+ require "json"
2
+
3
+ # One logical Dockerfile instruction: the keyword, its flags, and everything else on the
4
+ # line — continuations joined, heredoc bodies appended, comments dropped.
5
+ #
6
+ # `line` is the first physical line the instruction started on, because that is the line
7
+ # an operator opens their editor at when advice names it.
8
+ class Dash::Dockerfile::Instruction
9
+ attr_reader :name, :args, :flags, :line
10
+ attr_accessor :stage
11
+
12
+ def initialize(name:, args:, flags: {}, line: 1)
13
+ @name = name
14
+ @args = args
15
+ @flags = flags
16
+ @line = line
17
+ end
18
+
19
+ # The first value of a flag. Repeated flags (several `--mount`s on one RUN) keep every
20
+ # value in `flags`; callers that only care whether one is present ask for the first.
21
+ def flag(key)
22
+ Array(flags[key]).first
23
+ end
24
+
25
+ def flag?(key)
26
+ flags.key?(key)
27
+ end
28
+
29
+ def json?
30
+ args.start_with?("[") && !argv.nil?
31
+ end
32
+
33
+ # The exec form's arguments, or nil when this is the shell form (or malformed JSON,
34
+ # which BuildKit would reject but which must not take the analyzer down with it).
35
+ def argv
36
+ return @argv if defined?(@argv)
37
+
38
+ @argv = begin
39
+ parsed = JSON.parse(args) if args.start_with?("[")
40
+ parsed if parsed.is_a?(Array) && parsed.all?(String)
41
+ rescue JSON::ParserError
42
+ nil
43
+ end
44
+ end
45
+
46
+ # What the instruction runs, as one string, whichever form it was written in — the
47
+ # rules match against shell text and should not have to care.
48
+ def shell_command
49
+ json? ? argv.join(" ") : args
50
+ end
51
+
52
+ # Collapsed to a single line so it can be compared with what buildx printed for the
53
+ # matching vertex, and so advice can name it without wrapping the terminal.
54
+ def to_s
55
+ [ name, *flags.flat_map { |key, values| values.map { |value| "--#{key}=#{value}" } }, args ]
56
+ .reject(&:blank?).join(" ").gsub(/\s+/, " ")
57
+ end
58
+ end