kamal-lint 0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +52 -0
- data/MIT-LICENSE +21 -0
- data/README.md +190 -0
- data/action.yml +61 -0
- data/bin/kamal-lint +7 -0
- data/lib/kamal/lint/check.rb +90 -0
- data/lib/kamal/lint/checks/accessory_image_latest.rb +43 -0
- data/lib/kamal/lint/checks/accessory_placement_missing.rb +49 -0
- data/lib/kamal/lint/checks/accessory_role_undefined.rb +43 -0
- data/lib/kamal/lint/checks/boot_limit_exceeds_hosts.rb +34 -0
- data/lib/kamal/lint/checks/builder_registry_secret_undeclared.rb +42 -0
- data/lib/kamal/lint/checks/empty_web_role.rb +37 -0
- data/lib/kamal/lint/checks/image_registry_mismatch.rb +39 -0
- data/lib/kamal/lint/checks/kamal_secrets_not_gitignored.rb +56 -0
- data/lib/kamal/lint/checks/missing_proxy_healthcheck.rb +27 -0
- data/lib/kamal/lint/checks/missing_service_name.rb +46 -0
- data/lib/kamal/lint/checks/registry_without_explicit_server.rb +37 -0
- data/lib/kamal/lint/checks/role_hosts_empty.rb +35 -0
- data/lib/kamal/lint/checks/secret_in_env_clear.rb +45 -0
- data/lib/kamal/lint/checks/secret_not_declared.rb +58 -0
- data/lib/kamal/lint/checks/ssl_without_host.rb +37 -0
- data/lib/kamal/lint/checks/traefik_legacy_keys.rb +58 -0
- data/lib/kamal/lint/cli.rb +109 -0
- data/lib/kamal/lint/finding.rb +32 -0
- data/lib/kamal/lint/formatters/github.rb +55 -0
- data/lib/kamal/lint/formatters/human.rb +118 -0
- data/lib/kamal/lint/formatters/json.rb +38 -0
- data/lib/kamal/lint/kamal_version.rb +62 -0
- data/lib/kamal/lint/loader.rb +175 -0
- data/lib/kamal/lint/registry.rb +32 -0
- data/lib/kamal/lint/runner.rb +102 -0
- data/lib/kamal/lint/secrets_file.rb +29 -0
- data/lib/kamal/lint/servers_helper.rb +60 -0
- data/lib/kamal/lint/version.rb +7 -0
- data/lib/kamal/lint.rb +63 -0
- metadata +177 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
module Formatters
|
|
6
|
+
class Human
|
|
7
|
+
COLORS = {
|
|
8
|
+
reset: "\e[0m",
|
|
9
|
+
bold: "\e[1m",
|
|
10
|
+
dim: "\e[2m",
|
|
11
|
+
red: "\e[31m",
|
|
12
|
+
yellow: "\e[33m",
|
|
13
|
+
green: "\e[32m",
|
|
14
|
+
blue: "\e[34m",
|
|
15
|
+
magenta: "\e[35m",
|
|
16
|
+
cyan: "\e[36m",
|
|
17
|
+
gray: "\e[90m"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
SEVERITY_GLYPH = {
|
|
21
|
+
error: "✖",
|
|
22
|
+
warning: "⚠",
|
|
23
|
+
info: "•"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
SEVERITY_COLOR = {
|
|
27
|
+
error: :red,
|
|
28
|
+
warning: :yellow,
|
|
29
|
+
info: :blue
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
def initialize(io: $stdout, color: nil)
|
|
33
|
+
@io = io
|
|
34
|
+
@color = color.nil? ? io.tty? : color
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def render(result)
|
|
38
|
+
render_header(result)
|
|
39
|
+
|
|
40
|
+
if result.findings.empty?
|
|
41
|
+
@io.puts c(:green, " ✓ No issues found.")
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
result.findings
|
|
46
|
+
.sort_by { |f| [ SEVERITIES.index(f.severity), f.line || 0 ] }
|
|
47
|
+
.each { |finding| render_finding(finding) }
|
|
48
|
+
|
|
49
|
+
render_summary(result)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def render_fix_summary(result)
|
|
53
|
+
return if result.fixed.empty?
|
|
54
|
+
|
|
55
|
+
@io.puts
|
|
56
|
+
@io.puts c(:bold, "Applied autofixes:")
|
|
57
|
+
result.fixed.each do |finding|
|
|
58
|
+
@io.puts " #{c(:green, "✓")} [#{finding.check_id}] #{finding.message}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def render_header(result)
|
|
65
|
+
version = Kamal::Lint::VERSION
|
|
66
|
+
kamal = result.context.kamal_version || "?"
|
|
67
|
+
@io.puts "#{c(:bold, "kamal-lint")} #{c(:dim, version)} · kamal #{c(:cyan, kamal)} detected"
|
|
68
|
+
if (dest = result.context.destination)
|
|
69
|
+
@io.puts " destination: #{c(:cyan, dest)}"
|
|
70
|
+
end
|
|
71
|
+
@io.puts " config: #{c(:cyan, result.context.file_for_finding)}"
|
|
72
|
+
@io.puts
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def render_finding(finding)
|
|
76
|
+
loc = "#{finding.file}:#{finding.line || "?"}"
|
|
77
|
+
glyph = SEVERITY_GLYPH[finding.severity] || "?"
|
|
78
|
+
color = SEVERITY_COLOR[finding.severity] || :gray
|
|
79
|
+
sev = finding.severity.to_s.ljust(7)
|
|
80
|
+
fix_hint = finding.autofixable? ? c(:dim, " (autofixable)") : ""
|
|
81
|
+
|
|
82
|
+
@io.puts "#{c(color, glyph)} #{c(:bold, sev)} #{c(:gray, loc)}#{fix_hint}"
|
|
83
|
+
@io.puts " #{finding.message}"
|
|
84
|
+
@io.puts " #{c(:dim, "[#{finding.check_id}]")}"
|
|
85
|
+
@io.puts
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def render_summary(result)
|
|
89
|
+
errors = result.errors.size
|
|
90
|
+
warnings = result.warnings.size
|
|
91
|
+
infos = result.infos.size
|
|
92
|
+
autofixable = result.findings.count(&:autofixable?)
|
|
93
|
+
|
|
94
|
+
parts = []
|
|
95
|
+
parts << "#{c(:red, errors.to_s)} error#{plural(errors)}" if errors > 0
|
|
96
|
+
parts << "#{c(:yellow, warnings.to_s)} warning#{plural(warnings)}" if warnings > 0
|
|
97
|
+
parts << "#{c(:blue, infos.to_s)} info" if infos > 0
|
|
98
|
+
parts << "#{c(:dim, "#{autofixable} autofixable")}" if autofixable > 0
|
|
99
|
+
|
|
100
|
+
@io.puts c(:bold, "Summary: ") + parts.join(", ")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def plural(n)
|
|
104
|
+
n == 1 ? "" : "s"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def c(color, text)
|
|
108
|
+
return text unless @color
|
|
109
|
+
|
|
110
|
+
code = COLORS[color]
|
|
111
|
+
return text unless code
|
|
112
|
+
|
|
113
|
+
"#{code}#{text}#{COLORS[:reset]}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Kamal
|
|
6
|
+
module Lint
|
|
7
|
+
module Formatters
|
|
8
|
+
class Json
|
|
9
|
+
def initialize(io: $stdout)
|
|
10
|
+
@io = io
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render(result)
|
|
14
|
+
payload = {
|
|
15
|
+
kamal_lint_version: Kamal::Lint::VERSION,
|
|
16
|
+
kamal_version: result.context.kamal_version,
|
|
17
|
+
file: result.context.file_for_finding,
|
|
18
|
+
destination: result.context.destination,
|
|
19
|
+
findings: result.findings.map(&:to_h),
|
|
20
|
+
summary: {
|
|
21
|
+
errors: result.errors.size,
|
|
22
|
+
warnings: result.warnings.size,
|
|
23
|
+
infos: result.infos.size,
|
|
24
|
+
autofixable: result.findings.count(&:autofixable?)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
@io.puts JSON.pretty_generate(payload)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def render_fix_summary(result)
|
|
31
|
+
return if result.fixed.empty?
|
|
32
|
+
|
|
33
|
+
@io.puts JSON.pretty_generate(fixed: result.fixed.map(&:to_h))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Kamal
|
|
6
|
+
module Lint
|
|
7
|
+
# Detect the installed Kamal version from (in priority order):
|
|
8
|
+
# 1. explicit override
|
|
9
|
+
# 2. Bundler.locked_gems
|
|
10
|
+
# 3. Gem.loaded_specs / Gem::Specification
|
|
11
|
+
# 4. shell out to `kamal version`
|
|
12
|
+
module KamalVersion
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def detect(override: nil)
|
|
16
|
+
return normalize(override) if override
|
|
17
|
+
|
|
18
|
+
from_bundler || from_loaded_specs || from_shell
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def from_bundler
|
|
22
|
+
return nil unless defined?(Bundler)
|
|
23
|
+
|
|
24
|
+
locked = Bundler.locked_gems&.specs&.find { |s| s.name == "kamal" }
|
|
25
|
+
locked&.version&.to_s
|
|
26
|
+
rescue Bundler::GemfileNotFound
|
|
27
|
+
nil
|
|
28
|
+
rescue => _e
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def from_loaded_specs
|
|
33
|
+
spec = Gem.loaded_specs["kamal"] if defined?(Gem) && Gem.respond_to?(:loaded_specs)
|
|
34
|
+
return spec.version.to_s if spec
|
|
35
|
+
|
|
36
|
+
if defined?(Gem::Specification)
|
|
37
|
+
found = Gem::Specification.find_all_by_name("kamal").max_by(&:version)
|
|
38
|
+
return found&.version&.to_s
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
nil
|
|
42
|
+
rescue => _e
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def from_shell
|
|
47
|
+
out, _err, status = Open3.capture3("kamal", "version")
|
|
48
|
+
return nil unless status.success?
|
|
49
|
+
|
|
50
|
+
out.strip.split.last
|
|
51
|
+
rescue Errno::ENOENT
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def normalize(value)
|
|
56
|
+
return nil if value.nil? || value.to_s.empty?
|
|
57
|
+
|
|
58
|
+
value.to_s.strip
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "psych"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module Kamal
|
|
8
|
+
module Lint
|
|
9
|
+
# Holds all the data a check needs to inspect a single Kamal config:
|
|
10
|
+
# the parsed YAML, the source lines, helper to look up source lines for a
|
|
11
|
+
# given path (for finding line numbers), the destination override, the
|
|
12
|
+
# secrets file contents, and a flag indicating whether Kamal's own loader
|
|
13
|
+
# rejected the config (in which case some checks are skipped to avoid
|
|
14
|
+
# cascading false positives).
|
|
15
|
+
Context = Struct.new(
|
|
16
|
+
:config_file,
|
|
17
|
+
:destination,
|
|
18
|
+
:working_dir,
|
|
19
|
+
:parsed,
|
|
20
|
+
:base_parsed,
|
|
21
|
+
:override_parsed,
|
|
22
|
+
:source_lines,
|
|
23
|
+
:line_index,
|
|
24
|
+
:secrets,
|
|
25
|
+
:secrets_path,
|
|
26
|
+
:gitignore_path,
|
|
27
|
+
:kamal_version,
|
|
28
|
+
:kamal_loaded,
|
|
29
|
+
:kamal_load_error,
|
|
30
|
+
keyword_init: true
|
|
31
|
+
) do
|
|
32
|
+
def file_for_finding
|
|
33
|
+
return config_file unless destination
|
|
34
|
+
|
|
35
|
+
override_path = override_file
|
|
36
|
+
File.exist?(override_path) ? override_path : config_file
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def override_file
|
|
40
|
+
File.join(File.dirname(config_file), "deploy.#{destination}.yml")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def line_for(path)
|
|
44
|
+
line_index[Array(path).join(".")]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
module Loader
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
def load(config_file:, destination: nil, kamal_version: nil)
|
|
52
|
+
raise ConfigNotFoundError, "Config file not found: #{config_file}" unless File.exist?(config_file)
|
|
53
|
+
|
|
54
|
+
working_dir = Pathname.new(config_file).realpath.dirname.dirname.to_s
|
|
55
|
+
# If config_file is at config/deploy.yml inside a project, working_dir = project root.
|
|
56
|
+
# If the user pointed somewhere else, fall back to its parent dir's parent.
|
|
57
|
+
|
|
58
|
+
base_text = File.read(config_file)
|
|
59
|
+
base_parsed = safe_parse_yaml(base_text)
|
|
60
|
+
source_lines = base_text.lines
|
|
61
|
+
|
|
62
|
+
override_parsed = nil
|
|
63
|
+
if destination
|
|
64
|
+
override_path = File.join(File.dirname(config_file), "deploy.#{destination}.yml")
|
|
65
|
+
if File.exist?(override_path)
|
|
66
|
+
override_parsed = safe_parse_yaml(File.read(override_path))
|
|
67
|
+
source_lines = File.read(override_path).lines
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
merged = override_parsed ? deep_merge(base_parsed, override_parsed) : base_parsed
|
|
72
|
+
|
|
73
|
+
line_index = build_line_index(base_text)
|
|
74
|
+
secrets_path = File.join(working_dir, ".kamal", "secrets")
|
|
75
|
+
gitignore_path = File.join(working_dir, ".gitignore")
|
|
76
|
+
secrets_keys = SecretsFile.read_keys(secrets_path)
|
|
77
|
+
|
|
78
|
+
loaded = true
|
|
79
|
+
load_error = nil
|
|
80
|
+
begin
|
|
81
|
+
# Run Kamal's own loader for parse-level validation. We don't use the
|
|
82
|
+
# returned object — we keep working off the parsed Hash so we can
|
|
83
|
+
# report line numbers — but we surface Kamal's own errors as findings.
|
|
84
|
+
require "kamal"
|
|
85
|
+
Dir.chdir(working_dir) do
|
|
86
|
+
Kamal::Configuration.create_from(
|
|
87
|
+
config_file: Pathname.new(config_file),
|
|
88
|
+
destination: destination,
|
|
89
|
+
version: "kamal-lint"
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
rescue => e
|
|
93
|
+
loaded = false
|
|
94
|
+
load_error = e
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
Context.new(
|
|
98
|
+
config_file: config_file,
|
|
99
|
+
destination: destination,
|
|
100
|
+
working_dir: working_dir,
|
|
101
|
+
parsed: merged || {},
|
|
102
|
+
base_parsed: base_parsed || {},
|
|
103
|
+
override_parsed: override_parsed,
|
|
104
|
+
source_lines: source_lines,
|
|
105
|
+
line_index: line_index,
|
|
106
|
+
secrets: secrets_keys,
|
|
107
|
+
secrets_path: secrets_path,
|
|
108
|
+
gitignore_path: gitignore_path,
|
|
109
|
+
kamal_version: kamal_version || KamalVersion.detect,
|
|
110
|
+
kamal_loaded: loaded,
|
|
111
|
+
kamal_load_error: load_error
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def safe_parse_yaml(text)
|
|
116
|
+
result = YAML.safe_load(text, aliases: true, permitted_classes: [ Symbol ])
|
|
117
|
+
result.is_a?(Hash) ? result : {}
|
|
118
|
+
rescue Psych::SyntaxError
|
|
119
|
+
{}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def deep_merge(base, override)
|
|
123
|
+
return override unless base.is_a?(Hash) && override.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
result = base.dup
|
|
126
|
+
override.each do |k, v|
|
|
127
|
+
result[k] = if result[k].is_a?(Hash) && v.is_a?(Hash)
|
|
128
|
+
deep_merge(result[k], v)
|
|
129
|
+
else
|
|
130
|
+
v
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
result
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Build a mapping from dot-path ("env.secret") to source line numbers.
|
|
137
|
+
# Walks the Psych AST.
|
|
138
|
+
def build_line_index(text)
|
|
139
|
+
index = {}
|
|
140
|
+
stream = Psych.parse_stream(text)
|
|
141
|
+
stream.children.each do |doc|
|
|
142
|
+
walk_node(doc.root, [], index)
|
|
143
|
+
end
|
|
144
|
+
index
|
|
145
|
+
rescue Psych::SyntaxError
|
|
146
|
+
index
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def walk_node(node, path, index)
|
|
150
|
+
case node
|
|
151
|
+
when Psych::Nodes::Mapping
|
|
152
|
+
node.children.each_slice(2) do |key_node, value_node|
|
|
153
|
+
next unless key_node && value_node
|
|
154
|
+
|
|
155
|
+
key = key_node.value
|
|
156
|
+
new_path = path + [ key ]
|
|
157
|
+
index[new_path.join(".")] ||= key_node.start_line + 1
|
|
158
|
+
walk_node(value_node, new_path, index)
|
|
159
|
+
end
|
|
160
|
+
when Psych::Nodes::Sequence
|
|
161
|
+
node.children.each_with_index do |child, idx|
|
|
162
|
+
new_path = path + [ idx.to_s ]
|
|
163
|
+
# Index the position itself so checks can find sequence elements
|
|
164
|
+
# by ordinal (e.g. "env.secret.0").
|
|
165
|
+
index[new_path.join(".")] ||= child.start_line + 1
|
|
166
|
+
walk_node(child, new_path, index)
|
|
167
|
+
end
|
|
168
|
+
when Psych::Nodes::Scalar
|
|
169
|
+
# Scalars at non-root locations need no further indexing; their
|
|
170
|
+
# parent already wrote the line for them.
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
class Registry
|
|
6
|
+
def self.default
|
|
7
|
+
@default ||= new
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@checks = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def register(check_class)
|
|
15
|
+
@checks << check_class unless @checks.include?(check_class)
|
|
16
|
+
check_class
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def all
|
|
20
|
+
@checks.dup
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def applicable_to(kamal_version)
|
|
24
|
+
@checks.select { |c| c.applies_to?(kamal_version) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def find(id)
|
|
28
|
+
@checks.find { |c| c.id == id }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
Result = Struct.new(:findings, :context, :fixed, keyword_init: true) do
|
|
6
|
+
def errors
|
|
7
|
+
findings.select { |f| f.severity == :error }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def warnings
|
|
11
|
+
findings.select { |f| f.severity == :warning }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def infos
|
|
15
|
+
findings.select { |f| f.severity == :info }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def empty?
|
|
19
|
+
findings.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def exit_code(fail_on: :error)
|
|
23
|
+
threshold = SEVERITIES.index(fail_on.to_sym)
|
|
24
|
+
worst = findings.map { |f| SEVERITIES.index(f.severity) }.compact.min
|
|
25
|
+
return 0 if worst.nil?
|
|
26
|
+
|
|
27
|
+
worst <= threshold ? 1 : 0
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class Runner
|
|
32
|
+
def initialize(config_file:, destination: nil, kamal_version: nil,
|
|
33
|
+
registry: Lint.registry, fix: false, include_kamal_errors: false)
|
|
34
|
+
@config_file = config_file
|
|
35
|
+
@destination = destination
|
|
36
|
+
@kamal_version_override = kamal_version
|
|
37
|
+
@registry = registry
|
|
38
|
+
@fix = fix
|
|
39
|
+
@include_kamal_errors = include_kamal_errors
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def call
|
|
43
|
+
context = load_context
|
|
44
|
+
findings = collect_findings(context)
|
|
45
|
+
|
|
46
|
+
fixed = @fix ? apply_autofixes(findings, context) : []
|
|
47
|
+
|
|
48
|
+
if @fix && !fixed.empty?
|
|
49
|
+
context = load_context
|
|
50
|
+
findings = collect_findings(context)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Result.new(findings: findings, context: context, fixed: fixed)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def load_context
|
|
59
|
+
Loader.load(
|
|
60
|
+
config_file: @config_file,
|
|
61
|
+
destination: @destination,
|
|
62
|
+
kamal_version: @kamal_version_override
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def collect_findings(context)
|
|
67
|
+
findings = []
|
|
68
|
+
|
|
69
|
+
if @include_kamal_errors && context.kamal_load_error
|
|
70
|
+
findings << kamal_parse_error_finding(context)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@registry.applicable_to(context.kamal_version).each do |check_class|
|
|
74
|
+
findings.concat(Array(check_class.new(context).call))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
findings
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def kamal_parse_error_finding(context)
|
|
81
|
+
Finding.new(
|
|
82
|
+
check_id: "kamal-parse-error",
|
|
83
|
+
severity: :error,
|
|
84
|
+
message: "kamal could not load this config: #{context.kamal_load_error.message}",
|
|
85
|
+
file: context.file_for_finding,
|
|
86
|
+
line: 1,
|
|
87
|
+
column: 1,
|
|
88
|
+
autofix: nil
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def apply_autofixes(findings, context)
|
|
93
|
+
applied = []
|
|
94
|
+
findings.select(&:autofixable?).each do |finding|
|
|
95
|
+
ok = finding.autofix.call(context)
|
|
96
|
+
applied << finding if ok
|
|
97
|
+
end
|
|
98
|
+
applied
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
# Reads .kamal/secrets (shell-style KEY=value, with optional `export` prefix
|
|
6
|
+
# and `#` comments). Returns the set of declared keys. We don't expand or
|
|
7
|
+
# substitute — we only care whether a name is declared.
|
|
8
|
+
module SecretsFile
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def read_keys(path)
|
|
12
|
+
return [] unless path && File.exist?(path)
|
|
13
|
+
|
|
14
|
+
keys = []
|
|
15
|
+
File.foreach(path) do |raw|
|
|
16
|
+
line = raw.strip
|
|
17
|
+
next if line.empty?
|
|
18
|
+
next if line.start_with?("#")
|
|
19
|
+
|
|
20
|
+
line = line.sub(/\Aexport\s+/, "")
|
|
21
|
+
name, _eq, _value = line.partition("=")
|
|
22
|
+
name = name.strip
|
|
23
|
+
keys << name unless name.empty?
|
|
24
|
+
end
|
|
25
|
+
keys.uniq
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
# Helpers for walking `servers:` in its various shapes:
|
|
6
|
+
#
|
|
7
|
+
# servers: [host1, host2] → implicit "web" role
|
|
8
|
+
# servers: { web: [...], workers: [...] } → role => hosts
|
|
9
|
+
# servers: { web: { hosts: [...], cmd: ..., ... } } → role => expanded
|
|
10
|
+
module ServersHelper
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def role_names(servers)
|
|
14
|
+
case servers
|
|
15
|
+
when Array
|
|
16
|
+
[ "web" ]
|
|
17
|
+
when Hash
|
|
18
|
+
servers.keys.map(&:to_s)
|
|
19
|
+
else
|
|
20
|
+
[]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def hosts_for_role(servers, role)
|
|
25
|
+
case servers
|
|
26
|
+
when Array
|
|
27
|
+
role == "web" ? servers.dup : []
|
|
28
|
+
when Hash
|
|
29
|
+
entry = servers[role] || servers[role.to_sym]
|
|
30
|
+
extract_hosts(entry)
|
|
31
|
+
else
|
|
32
|
+
[]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def all_hosts(servers)
|
|
37
|
+
case servers
|
|
38
|
+
when Array
|
|
39
|
+
servers.dup
|
|
40
|
+
when Hash
|
|
41
|
+
servers.values.flat_map { |v| extract_hosts(v) }
|
|
42
|
+
else
|
|
43
|
+
[]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def extract_hosts(entry)
|
|
48
|
+
case entry
|
|
49
|
+
when Array
|
|
50
|
+
entry
|
|
51
|
+
when Hash
|
|
52
|
+
hosts = entry["hosts"] || entry[:hosts] || []
|
|
53
|
+
hosts.is_a?(Array) ? hosts : [ hosts ].compact
|
|
54
|
+
else
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/kamal/lint.rb
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lint/version"
|
|
4
|
+
|
|
5
|
+
module Kamal
|
|
6
|
+
module Lint
|
|
7
|
+
SEVERITIES = %i[error warning info].freeze
|
|
8
|
+
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
class ConfigNotFoundError < Error; end
|
|
12
|
+
|
|
13
|
+
def self.registry
|
|
14
|
+
Registry.default
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.formatters
|
|
18
|
+
FORMATTERS
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
require_relative "lint/finding"
|
|
24
|
+
require_relative "lint/check"
|
|
25
|
+
require_relative "lint/registry"
|
|
26
|
+
require_relative "lint/secrets_file"
|
|
27
|
+
require_relative "lint/servers_helper"
|
|
28
|
+
require_relative "lint/loader"
|
|
29
|
+
require_relative "lint/kamal_version"
|
|
30
|
+
require_relative "lint/runner"
|
|
31
|
+
|
|
32
|
+
require_relative "lint/formatters/human"
|
|
33
|
+
require_relative "lint/formatters/json"
|
|
34
|
+
require_relative "lint/formatters/github"
|
|
35
|
+
|
|
36
|
+
module Kamal
|
|
37
|
+
module Lint
|
|
38
|
+
FORMATTERS = {
|
|
39
|
+
"human" => Formatters::Human,
|
|
40
|
+
"json" => Formatters::Json,
|
|
41
|
+
"github" => Formatters::Github
|
|
42
|
+
}.freeze
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
require_relative "lint/checks/secret_not_declared"
|
|
47
|
+
require_relative "lint/checks/accessory_role_undefined"
|
|
48
|
+
require_relative "lint/checks/role_hosts_empty"
|
|
49
|
+
require_relative "lint/checks/image_registry_mismatch"
|
|
50
|
+
require_relative "lint/checks/builder_registry_secret_undeclared"
|
|
51
|
+
require_relative "lint/checks/ssl_without_host"
|
|
52
|
+
require_relative "lint/checks/empty_web_role"
|
|
53
|
+
require_relative "lint/checks/traefik_legacy_keys"
|
|
54
|
+
require_relative "lint/checks/boot_limit_exceeds_hosts"
|
|
55
|
+
require_relative "lint/checks/accessory_placement_missing"
|
|
56
|
+
require_relative "lint/checks/missing_service_name"
|
|
57
|
+
require_relative "lint/checks/kamal_secrets_not_gitignored"
|
|
58
|
+
require_relative "lint/checks/secret_in_env_clear"
|
|
59
|
+
require_relative "lint/checks/missing_proxy_healthcheck"
|
|
60
|
+
require_relative "lint/checks/accessory_image_latest"
|
|
61
|
+
require_relative "lint/checks/registry_without_explicit_server"
|
|
62
|
+
|
|
63
|
+
require_relative "lint/cli"
|