dash 4.0.8 → 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.
Files changed (60) 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 +20 -10
  6. data/lib/dash/cli/app.rb +2 -2
  7. data/lib/dash/cli/base.rb +117 -2
  8. data/lib/dash/cli/build.rb +61 -5
  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/main.rb +24 -7
  12. data/lib/dash/cli/prune.rb +5 -8
  13. data/lib/dash/cli/report.rb +97 -0
  14. data/lib/dash/cli/templates/sample_hooks/post-deploy.sample +5 -0
  15. data/lib/dash/commander.rb +10 -2
  16. data/lib/dash/commands/app.rb +18 -0
  17. data/lib/dash/commands/auditor.rb +10 -0
  18. data/lib/dash/commands/builder/base.rb +18 -0
  19. data/lib/dash/commands/builder.rb +1 -1
  20. data/lib/dash/configuration/docs/configuration.yml +6 -0
  21. data/lib/dash/configuration/docs/report.yml +39 -0
  22. data/lib/dash/configuration/report.rb +65 -0
  23. data/lib/dash/configuration.rb +2 -1
  24. data/lib/dash/dockerfile/analyzer.rb +66 -0
  25. data/lib/dash/dockerfile/context.rb +147 -0
  26. data/lib/dash/dockerfile/dockerignore.rb +29 -0
  27. data/lib/dash/dockerfile/document.rb +28 -0
  28. data/lib/dash/dockerfile/finding.rb +20 -0
  29. data/lib/dash/dockerfile/hadolint.rb +75 -0
  30. data/lib/dash/dockerfile/instruction.rb +58 -0
  31. data/lib/dash/dockerfile/parser.rb +199 -0
  32. data/lib/dash/dockerfile/rules/apt_hygiene.rb +35 -0
  33. data/lib/dash/dockerfile/rules/base.rb +44 -0
  34. data/lib/dash/dockerfile/rules/cache_busting_arg.rb +40 -0
  35. data/lib/dash/dockerfile/rules/cache_export_cost.rb +20 -0
  36. data/lib/dash/dockerfile/rules/context_size.rb +20 -0
  37. data/lib/dash/dockerfile/rules/copy_before_install.rb +34 -0
  38. data/lib/dash/dockerfile/rules/curl_pipe_shell.rb +16 -0
  39. data/lib/dash/dockerfile/rules/dockerignore_gaps.rb +36 -0
  40. data/lib/dash/dockerfile/rules/inline_env_blob.rb +23 -0
  41. data/lib/dash/dockerfile/rules/latest_base.rb +24 -0
  42. data/lib/dash/dockerfile/rules/missing_dockerignore.rb +11 -0
  43. data/lib/dash/dockerfile/rules/no_cache_mount.rb +26 -0
  44. data/lib/dash/dockerfile/rules/root_user.rb +12 -0
  45. data/lib/dash/dockerfile/rules/secret_in_build_arg.rb +30 -0
  46. data/lib/dash/dockerfile/rules/single_stage_build_deps.rb +19 -0
  47. data/lib/dash/dockerfile/rules/uncached_install.rb +20 -0
  48. data/lib/dash/dockerfile/stage.rb +65 -0
  49. data/lib/dash/otel_shipper.rb +5 -4
  50. data/lib/dash/output/otel_logger.rb +52 -0
  51. data/lib/dash/report/history.rb +94 -0
  52. data/lib/dash/report/trends.rb +129 -0
  53. data/lib/dash/report/writer.rb +142 -0
  54. data/lib/dash/report.rb +170 -0
  55. data/lib/dash/sshkit_with_ext.rb +62 -0
  56. data/lib/dash/timings.rb +163 -10
  57. data/lib/dash/utils.rb +7 -0
  58. data/lib/dash/version.rb +1 -1
  59. data/lib/dash.rb +4 -0
  60. metadata +36 -1
@@ -17,7 +17,8 @@ class Dash::Cli::Doctor
17
17
  ports: "Ports",
18
18
  dns: "DNS",
19
19
  certificate: "Certificates",
20
- readiness: "Readiness"
20
+ readiness: "Readiness",
21
+ dockerfile: "Dockerfile"
21
22
  }.freeze
22
23
 
23
24
  STATUS_COLORS = { ok: :green, warn: :yellow, fail: :red }.freeze
data/lib/dash/cli/main.rb CHANGED
@@ -26,16 +26,23 @@ class Dash::Cli::Main < Dash::Cli::Base
26
26
  print_config_banner
27
27
 
28
28
  say "Validate configuration and secrets...", :magenta
29
- DASH.config.validate_secrets!(include_accessories: boot_accessories)
29
+ timed("Validate config and secrets") { DASH.config.validate_secrets!(include_accessories: boot_accessories) }
30
30
 
31
31
  if options[:skip_push]
32
32
  say "Pull app image...", :magenta
33
33
  timed("Pull app image") { invoke "dash:cli:build:pull", [], invoke_options }
34
34
  else
35
35
  say "Build and push app image...", :magenta
36
- timed("Build and push app image") { invoke "dash:cli:build:deliver", [], invoke_options }
36
+ timed("Build and push app image") do |entry|
37
+ DASH.report.build_entry = entry
38
+ invoke "dash:cli:build:deliver", [], invoke_options
39
+ end
37
40
  end
38
41
 
42
+ # Before the boot, so the advice still prints when a boot fails — a slow build is
43
+ # exactly the kind of thing an operator wants to see on a deploy that went wrong.
44
+ analyze_report
45
+
39
46
  modify(lock: true) do
40
47
  run_hook "pre-deploy", secrets: true
41
48
 
@@ -59,7 +66,7 @@ class Dash::Cli::Main < Dash::Cli::Base
59
66
  end
60
67
  end
61
68
 
62
- run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s
69
+ run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s, **report_hook_details
63
70
  end
64
71
  end
65
72
 
@@ -74,16 +81,23 @@ class Dash::Cli::Main < Dash::Cli::Base
74
81
  print_config_banner
75
82
 
76
83
  say "Validate configuration and secrets...", :magenta
77
- DASH.config.validate_secrets!
84
+ timed("Validate config and secrets") { DASH.config.validate_secrets! }
78
85
 
79
86
  if options[:skip_push]
80
87
  say "Pull app image...", :magenta
81
88
  timed("Pull app image") { invoke "dash:cli:build:pull", [], invoke_options }
82
89
  else
83
90
  say "Build and push app image...", :magenta
84
- timed("Build and push app image") { invoke "dash:cli:build:deliver", [], invoke_options }
91
+ timed("Build and push app image") do |entry|
92
+ DASH.report.build_entry = entry
93
+ invoke "dash:cli:build:deliver", [], invoke_options
94
+ end
85
95
  end
86
96
 
97
+ # Before the boot, so the advice still prints when a boot fails — a slow build is
98
+ # exactly the kind of thing an operator wants to see on a deploy that went wrong.
99
+ analyze_report
100
+
87
101
  modify(lock: true) do
88
102
  run_hook "pre-deploy", secrets: true
89
103
 
@@ -99,7 +113,7 @@ class Dash::Cli::Main < Dash::Cli::Base
99
113
  end
100
114
  end
101
115
 
102
- run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s
116
+ run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s, **report_hook_details
103
117
  end
104
118
  end
105
119
 
@@ -125,7 +139,7 @@ class Dash::Cli::Main < Dash::Cli::Base
125
139
  end
126
140
  end
127
141
 
128
- run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s if rolled_back
142
+ run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s, **report_hook_details if rolled_back
129
143
  end
130
144
  end
131
145
 
@@ -299,6 +313,9 @@ class Dash::Cli::Main < Dash::Cli::Base
299
313
  desc "prune", "Prune old application images and containers"
300
314
  subcommand "prune", Dash::Cli::Prune
301
315
 
316
+ desc "report", "Read the deploy reports saved under .dash/reports"
317
+ subcommand "report", Dash::Cli::Report
318
+
302
319
  desc "registry", "Login and -out of the image registry"
303
320
  subcommand "registry", Dash::Cli::Registry
304
321
 
@@ -11,9 +11,7 @@ class Dash::Cli::Prune < Dash::Cli::Base
11
11
  def images
12
12
  modify(lock: true, server_lock: true) do
13
13
  on(DASH.hosts) do
14
- execute *DASH.auditor.record("Pruned images"), verbosity: :debug
15
- execute *DASH.prune.dangling_images
16
- execute *DASH.prune.tagged_images
14
+ execute *DASH.auditor.record_then("Pruned images", DASH.prune.dangling_images, DASH.prune.tagged_images)
17
15
  end
18
16
  end
19
17
  end
@@ -26,11 +24,10 @@ class Dash::Cli::Prune < Dash::Cli::Base
26
24
 
27
25
  modify(lock: true, server_lock: true) do
28
26
  on(DASH.hosts) do |host|
29
- execute *DASH.auditor.record("Pruned containers"), verbosity: :debug
30
-
31
- DASH.roles_on(host).each do |role|
32
- execute *DASH.prune.app_containers(retain: retain, role: role)
33
- end
27
+ # One round trip per host, whatever it runs: a host with no app roles still
28
+ # records that the sweep reached it.
29
+ execute *DASH.auditor.record_then("Pruned containers",
30
+ *DASH.roles_on(host).map { |role| DASH.prune.app_containers(retain: retain, role: role) })
34
31
  end
35
32
  end
36
33
  end
@@ -0,0 +1,97 @@
1
+ require "json"
2
+
3
+ # Reads the JSON reports every deploy leaves under `.dash/reports`.
4
+ #
5
+ # Entirely local and read-only: no lock, no SSH, nothing that can change a server. It is
6
+ # the command to reach for after a deploy has finished and the table has scrolled away,
7
+ # and the one that answers "was it always this slow?".
8
+ class Dash::Cli::Report < Dash::Cli::Base
9
+ default_command :show
10
+
11
+ TREND_HEADINGS = %w[ started version total build boot advice ].freeze
12
+ COLUMNS = " %-20s %-10s %8s %8s %8s %s".freeze
13
+
14
+ desc "show", "Print the last saved deploy report"
15
+ option :last, type: :numeric, banner: "N", desc: "Print a trend table over the last N reports instead"
16
+ def show
17
+ if (last = options[:last])
18
+ return say "--last takes a positive number of reports, got #{last}", :red unless count?(last)
19
+
20
+ print_trend saved.recent(last.to_i)
21
+ else
22
+ print_latest saved.recent(1).first
23
+ end
24
+ end
25
+
26
+ desc "path", "Print the directory saved reports are written to"
27
+ def path
28
+ puts reports_directory
29
+ end
30
+
31
+ private
32
+ # Thor's :numeric happily hands over -1 or 2.5, which Array#first turns into a
33
+ # backtrace. A typo in a flag deserves a sentence, not a stack trace.
34
+ def count?(value)
35
+ value.to_i == value && value.to_i > 0
36
+ end
37
+
38
+ def saved
39
+ Dash::Report::History.new(reports_directory, destination: DASH.config.destination)
40
+ end
41
+
42
+ def print_latest(document)
43
+ return say_nothing_saved unless document
44
+
45
+ say "Deploy report for #{subject}", :magenta
46
+ puts summary_line(document)
47
+ puts Dash::Report.from_h(document).lines
48
+ end
49
+
50
+ def print_trend(documents)
51
+ return say_nothing_saved if documents.empty?
52
+
53
+ say "Last #{documents.size} #{"report".pluralize(documents.size)} for #{subject}", :magenta
54
+ puts format(COLUMNS, *TREND_HEADINGS)
55
+ documents.reverse_each { |document| puts trend_row(document) }
56
+ end
57
+
58
+ def subject
59
+ [ DASH.config.service, ("to #{DASH.config.destination}" if DASH.config.destination) ].compact.join(" ")
60
+ end
61
+
62
+ def summary_line(document)
63
+ " #{document[:command]} #{document[:status]} in #{seconds(document[:runtime])} at #{document[:started_at]}" \
64
+ "#{" (version #{document[:version]})" if document[:version]}#{error_note(document)}"
65
+ end
66
+
67
+ def error_note(document)
68
+ " — #{document.dig(:error, :class)}: #{document.dig(:error, :message)}" if document[:error]
69
+ end
70
+
71
+ def trend_row(document)
72
+ format COLUMNS, document[:started_at], (document[:version] || "").to_s[0...10],
73
+ seconds(document[:runtime]), phase(document, Dash::Report::Trends::BUILD_PHASE),
74
+ phase(document, Dash::Report::Trends::BOOT_PHASE), advice_count(document)
75
+ end
76
+
77
+ def phase(document, name)
78
+ found = Array(document[:phases]).find { |candidate| candidate[:name] == name && candidate[:depth].to_i.zero? }
79
+
80
+ found ? seconds(found[:seconds]) : "-"
81
+ end
82
+
83
+ def advice_count(document)
84
+ findings = Array(document[:advice])
85
+ warnings = findings.count { |finding| finding[:severity] == "warn" }
86
+
87
+ "#{findings.size}#{" (#{warnings} warn)" if warnings > 0}"
88
+ end
89
+
90
+ def seconds(value)
91
+ format("%.1fs", value.to_f)
92
+ end
93
+
94
+ def say_nothing_saved
95
+ say "No saved reports for #{subject} in #{reports_directory}", :yellow
96
+ end
97
+ end
@@ -10,5 +10,10 @@
10
10
  # DASH_ROLES (if set)
11
11
  # DASH_DESTINATION (if set)
12
12
  # DASH_RUNTIME
13
+ # DASH_BUILD_RUNTIME (if the deploy built an image)
14
+ # DASH_BOOT_RUNTIME (if the deploy booted containers)
15
+ # DASH_ADVICE_COUNT
16
+ # DASH_ADVICE_WARNINGS
17
+ # DASH_REPORT_PATH (unless report/history is 0)
13
18
 
14
19
  echo "$DASH_PERFORMER deployed $DASH_VERSION to $DASH_DESTINATION in $DASH_RUNTIME seconds"
@@ -6,7 +6,13 @@ require "active_support/notifications"
6
6
 
7
7
  class Dash::Commander
8
8
  attr_accessor :verbosity, :holding_lock, :holding_server_lock, :connected, :logging, :lock_wait, :lock_wait_timeout, :lock_wait_interval
9
- attr_reader :specific_roles, :specific_hosts, :timings
9
+ attr_reader :specific_roles, :specific_hosts, :timings, :report
10
+
11
+ # Hosts whose run directory this process has already swept, so the second lock acquire
12
+ # of a command does not re-run the migration everywhere. Per host rather than a flag:
13
+ # `dash upgrade` narrows the host set between acquires, and a host that was never in
14
+ # scope has never been swept.
15
+ attr_reader :run_directory_ensured_on
10
16
  delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :app_hosts, :proxy_hosts, :accessory_hosts, to: :specifics
11
17
 
12
18
  def initialize
@@ -24,10 +30,12 @@ class Dash::Commander
24
30
  self.lock_wait_interval = 15
25
31
  @modify_depth = 0
26
32
  @timings = Dash::Timings.new
33
+ @report = Dash::Report.new(timings: @timings)
27
34
  @specifics = @specific_roles = @specific_hosts = nil
28
35
  @config = @config_kwargs = nil
29
36
  @output_logger = nil
30
37
  @commands = {}
38
+ @run_directory_ensured_on = []
31
39
  end
32
40
 
33
41
  def config
@@ -170,7 +178,7 @@ class Dash::Commander
170
178
  @logging = true
171
179
  if modify_started
172
180
  ActiveSupport::Notifications.instrument("modify.kamal",
173
- command: command, subcommand: subcommand, destination: config.destination, hosts: hosts) { yield }
181
+ command: command, subcommand: subcommand, destination: config.destination, hosts: hosts, report: report) { yield }
174
182
  else
175
183
  yield
176
184
  end
@@ -3,6 +3,10 @@ class Dash::Commands::App < Dash::Commands::Base
3
3
 
4
4
  ACTIVE_DOCKER_STATUSES = [ :running, :restarting ]
5
5
 
6
+ # Separates the two answers #boot_state returns. A container id is hex and a version is
7
+ # a name suffix, so neither can produce this line on its own.
8
+ BOOT_STATE_SEPARATOR = "--%--"
9
+
6
10
  attr_reader :role, :host
7
11
 
8
12
  delegate :container_name, to: :role
@@ -75,6 +79,20 @@ class Dash::Commands::App < Dash::Commands::Base
75
79
  extract_version_from_name
76
80
  end
77
81
 
82
+ # Everything a boot needs to know about a host before it starts anything: whether a
83
+ # container for the version being deployed already exists (so it can be renamed out of
84
+ # the way) and which version is running now (so it can be stopped once the new one is
85
+ # live). Two questions, one round trip, answers split on BOOT_STATE_SEPARATOR.
86
+ #
87
+ # Chained with `;` rather than `&&`: an empty answer to either is a normal result, not
88
+ # a failure, and the second question must be asked whatever the first one said.
89
+ def boot_state(version)
90
+ chain \
91
+ container_id_for_version(version),
92
+ [ :echo, BOOT_STATE_SEPARATOR ],
93
+ current_running_version
94
+ end
95
+
78
96
  def list_versions(*docker_args, statuses: nil)
79
97
  pipe \
80
98
  docker(:ps, *container_filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'),
@@ -14,6 +14,16 @@ class Dash::Commands::Auditor < Dash::Commands::Base
14
14
  append([ :echo, escape_shell_value(audit_line(line, **details)) ], audit_log_file)
15
15
  end
16
16
 
17
+ # The audit line and the action it describes in one round trip, still in that order:
18
+ # the log is written first, and `&&` means a failed write aborts the action exactly as
19
+ # a failed standalone audit would have.
20
+ #
21
+ # Only ever fold in commands the caller would `execute`. A `capture` folded in here
22
+ # would come back with nothing to distinguish the audit's own output from the answer.
23
+ def record_then(line, *commands, **details)
24
+ combine record(line, **details), *commands
25
+ end
26
+
17
27
  def reveal
18
28
  [ :tail, "-n", 50, audit_log_file ]
19
29
  end
@@ -14,9 +14,27 @@ class Dash::Commands::Builder::Base < Dash::Commands::Base
14
14
  docker :image, :rm, "--force", config.absolute_image
15
15
  end
16
16
 
17
+ # Dropping the old image is housekeeping - a host that never had it is not an error -
18
+ # so it must not short-circuit whatever it shares a round trip with.
19
+ #
20
+ # The `|| true` is parenthesised because `&&` and `||` bind equally and associate left:
21
+ # ungrouped, an `audit && clean || true && pull` chain lets a FAILED audit fall into the
22
+ # same `|| true` and pull anyway, exit status 0. The group confines it to the clean.
23
+ #
24
+ # Composed only, never executed on its own: SSHKit's command map prefixes an unknown
25
+ # first word with /usr/bin/env, and the first word here is `(`.
26
+ def clean_then_pull
27
+ combine [ "(", *any(clean, [ :true ]), ")" ], pull
28
+ end
29
+
17
30
  def push(export_action = "registry", tag_as_dirty: false, no_cache: false)
18
31
  docker :buildx, :build,
19
32
  "--output=type=#{export_action}",
33
+ # Plain progress is what dash parses into the build rows of the deploy report
34
+ # (Dash::Build::ProgressParser). buildx already falls back to it when stdout is a
35
+ # pipe, which it always is under SSHKit — this only pins it so the format cannot
36
+ # change under us.
37
+ "--progress=plain",
20
38
  *platform_options(arches),
21
39
  *([ "--builder", builder_name ] unless docker_driver?),
22
40
  *build_tag_options(tag_as_dirty: tag_as_dirty),
@@ -2,7 +2,7 @@ require "active_support/core_ext/string/filters"
2
2
 
3
3
  class Dash::Commands::Builder < Dash::Commands::Base
4
4
  delegate \
5
- :create, :remove, :dev, :push, :clean, :pull, :info, :inspect_builder,
5
+ :create, :remove, :dev, :push, :clean, :pull, :clean_then_pull, :info, :inspect_builder,
6
6
  :validate_image, :first_mirror, :login_to_registry_locally?, :push_env,
7
7
  to: :target
8
8
 
@@ -229,6 +229,12 @@ logging:
229
229
  output:
230
230
  ...
231
231
 
232
+ # Deploy report
233
+ #
234
+ # Advice printed under the deploy timing table, see dash docs report:
235
+ report:
236
+ ...
237
+
232
238
  # Aliases
233
239
  #
234
240
  # Alias configuration, see dash docs alias:
@@ -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
@@ -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