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,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.
|
|
111
|
-
#
|
|
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
|
|
119
|
-
#
|
|
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.
|
|
124
|
-
#
|
|
125
|
-
#
|
|
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
|
data/lib/dash/configuration.rb
CHANGED
|
@@ -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
|