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,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
module Checks
|
|
6
|
+
class ImageRegistryMismatch < Check
|
|
7
|
+
id "image-registry-mismatch"
|
|
8
|
+
severity :error
|
|
9
|
+
since "2.0.0"
|
|
10
|
+
title "`image:` registry prefix doesn't match `builder.registry.server`"
|
|
11
|
+
|
|
12
|
+
# Docker Hub accepts unprefixed images (`myorg/myapp` resolves to
|
|
13
|
+
# `docker.io/myorg/myapp`), so we don't flag a missing prefix when the
|
|
14
|
+
# configured registry is Docker Hub under any of its canonical names.
|
|
15
|
+
DOCKER_HUB_HOSTS = %w[docker.io index.docker.io registry.hub.docker.com].freeze
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
image = parsed["image"]
|
|
19
|
+
registry = parsed["registry"] || parsed.dig("builder", "registry") || {}
|
|
20
|
+
server = registry["server"] if registry.is_a?(Hash)
|
|
21
|
+
return [] unless image.is_a?(String) && server.is_a?(String) && !server.empty?
|
|
22
|
+
|
|
23
|
+
normalized_server = server.sub(%r{/+\z}, "")
|
|
24
|
+
return [] if DOCKER_HUB_HOSTS.include?(normalized_server)
|
|
25
|
+
|
|
26
|
+
prefix = "#{normalized_server}/"
|
|
27
|
+
return [] if image.start_with?(prefix)
|
|
28
|
+
|
|
29
|
+
[ finding(
|
|
30
|
+
message: "image `#{image}` does not include the configured registry `#{server}`; Kamal will push to the wrong registry",
|
|
31
|
+
line: context.line_for([ "image" ])
|
|
32
|
+
) ]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Lint.registry.register(ImageRegistryMismatch)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
module Checks
|
|
6
|
+
class KamalSecretsNotGitignored < Check
|
|
7
|
+
id "kamal-secrets-not-gitignored"
|
|
8
|
+
severity :warning
|
|
9
|
+
since "2.0.0"
|
|
10
|
+
autofixable true
|
|
11
|
+
title ".kamal/secrets is not in .gitignore"
|
|
12
|
+
|
|
13
|
+
# We only flag when:
|
|
14
|
+
# - a .kamal/secrets file exists (so there's something to leak), AND
|
|
15
|
+
# - it is NOT covered by .gitignore (or .gitignore is missing).
|
|
16
|
+
def call
|
|
17
|
+
return [] unless File.exist?(context.secrets_path)
|
|
18
|
+
return [] if gitignored?
|
|
19
|
+
|
|
20
|
+
[ finding(
|
|
21
|
+
message: ".kamal/secrets exists but isn't ignored by .gitignore; you risk committing real secrets",
|
|
22
|
+
line: 1,
|
|
23
|
+
autofix: method(:apply_fix)
|
|
24
|
+
) ]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def gitignored?
|
|
28
|
+
return false unless File.exist?(context.gitignore_path)
|
|
29
|
+
|
|
30
|
+
File.foreach(context.gitignore_path).any? do |line|
|
|
31
|
+
stripped = line.strip
|
|
32
|
+
next false if stripped.empty? || stripped.start_with?("#")
|
|
33
|
+
|
|
34
|
+
stripped == ".kamal/secrets" ||
|
|
35
|
+
stripped == "/.kamal/secrets" ||
|
|
36
|
+
stripped == ".kamal/*" ||
|
|
37
|
+
stripped == ".kamal/" ||
|
|
38
|
+
stripped == ".kamal"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def apply_fix(ctx)
|
|
43
|
+
path = ctx.gitignore_path
|
|
44
|
+
existing = File.exist?(path) ? File.read(path) : ""
|
|
45
|
+
existing = existing + "\n" unless existing.empty? || existing.end_with?("\n")
|
|
46
|
+
File.write(path, "#{existing}.kamal/secrets\n")
|
|
47
|
+
true
|
|
48
|
+
rescue => _e
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Lint.registry.register(KamalSecretsNotGitignored)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
module Checks
|
|
6
|
+
class MissingProxyHealthcheck < Check
|
|
7
|
+
id "missing-proxy-healthcheck"
|
|
8
|
+
severity :warning
|
|
9
|
+
since "2.0.0"
|
|
10
|
+
title "`proxy:` block has no healthcheck — zero-downtime deploys may fail"
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
proxy = parsed["proxy"]
|
|
14
|
+
return [] unless proxy.is_a?(Hash)
|
|
15
|
+
return [] if proxy.key?("healthcheck")
|
|
16
|
+
|
|
17
|
+
[ finding(
|
|
18
|
+
message: "proxy block has no `healthcheck:` configured; Kamal-proxy can't verify a new release before cutover",
|
|
19
|
+
line: context.line_for([ "proxy" ])
|
|
20
|
+
) ]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Lint.registry.register(MissingProxyHealthcheck)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
module Checks
|
|
6
|
+
class MissingServiceName < Check
|
|
7
|
+
id "missing-service-name"
|
|
8
|
+
severity :error
|
|
9
|
+
since "2.0.0"
|
|
10
|
+
autofixable true
|
|
11
|
+
title "`service:` is required and missing"
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
service = parsed["service"]
|
|
15
|
+
return [] if service.is_a?(String) && !service.strip.empty?
|
|
16
|
+
|
|
17
|
+
[ finding(
|
|
18
|
+
message: "`service:` is required; without it Kamal can't name the deployed container",
|
|
19
|
+
line: 1,
|
|
20
|
+
autofix: method(:apply_fix)
|
|
21
|
+
) ]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def apply_fix(ctx)
|
|
25
|
+
file = ctx.file_for_finding
|
|
26
|
+
text = File.read(file)
|
|
27
|
+
parsed = YAML.safe_load(text, aliases: true) || {}
|
|
28
|
+
return false if parsed["service"].is_a?(String) && !parsed["service"].empty?
|
|
29
|
+
|
|
30
|
+
name = File.basename(ctx.working_dir).gsub(/[^A-Za-z0-9_-]/, "-")
|
|
31
|
+
return false if name.empty?
|
|
32
|
+
|
|
33
|
+
# Parse-and-dump so the fix composes safely with other autofixes
|
|
34
|
+
# that may also rewrite the file. The trade-off is that comments
|
|
35
|
+
# in the original YAML are lost — documented in the README.
|
|
36
|
+
File.write(file, YAML.dump({ "service" => name }.merge(parsed)))
|
|
37
|
+
true
|
|
38
|
+
rescue
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Lint.registry.register(MissingServiceName)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
module Checks
|
|
6
|
+
class RegistryWithoutExplicitServer < Check
|
|
7
|
+
id "registry-without-explicit-server"
|
|
8
|
+
severity :warning
|
|
9
|
+
since "2.0.0"
|
|
10
|
+
title "`registry.server` not set; image will default to Docker Hub"
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
registry = parsed["registry"] || parsed.dig("builder", "registry")
|
|
14
|
+
return [] unless registry.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
server = registry["server"]
|
|
17
|
+
return [] if server.is_a?(String) && !server.empty?
|
|
18
|
+
|
|
19
|
+
image = parsed["image"]
|
|
20
|
+
# If image already has an explicit registry prefix (host with a "."),
|
|
21
|
+
# this is intentional and we don't warn.
|
|
22
|
+
if image.is_a?(String) && image.include?("/")
|
|
23
|
+
first_segment = image.split("/", 2).first
|
|
24
|
+
return [] if first_segment.include?(".") || first_segment.include?(":")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
[ finding(
|
|
28
|
+
message: "registry has no `server:` set; Kamal will push/pull from Docker Hub by default",
|
|
29
|
+
line: context.line_for([ "registry" ]) || context.line_for([ "registry", "username" ])
|
|
30
|
+
) ]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Lint.registry.register(RegistryWithoutExplicitServer)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../servers_helper"
|
|
4
|
+
|
|
5
|
+
module Kamal
|
|
6
|
+
module Lint
|
|
7
|
+
module Checks
|
|
8
|
+
class RoleHostsEmpty < Check
|
|
9
|
+
id "role-hosts-empty"
|
|
10
|
+
severity :error
|
|
11
|
+
since "2.0.0"
|
|
12
|
+
title "A role under `servers:` has no hosts"
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
servers = parsed["servers"]
|
|
16
|
+
return [] unless servers.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
findings = []
|
|
19
|
+
servers.each do |role, entry|
|
|
20
|
+
hosts = ServersHelper.extract_hosts(entry)
|
|
21
|
+
next unless hosts.empty?
|
|
22
|
+
|
|
23
|
+
findings << finding(
|
|
24
|
+
message: "role `#{role}` under `servers` has no hosts; deploys to this role will silently no-op",
|
|
25
|
+
line: context.line_for([ "servers", role.to_s ])
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
findings
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Lint.registry.register(RoleHostsEmpty)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
module Checks
|
|
6
|
+
class SecretInEnvClear < Check
|
|
7
|
+
id "secret-in-env-clear"
|
|
8
|
+
severity :warning
|
|
9
|
+
since "2.0.0"
|
|
10
|
+
title "Value in `env.clear` looks like a secret"
|
|
11
|
+
|
|
12
|
+
SECRET_KEY_PATTERN = /(\A|_)(KEY|SECRET|TOKEN|PASSWORD|PWD|CREDENTIALS?)(_|\z)/i
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
findings = []
|
|
16
|
+
scan_env(parsed["env"], [ "env" ], findings)
|
|
17
|
+
(parsed["accessories"] || {}).each do |name, accessory|
|
|
18
|
+
scan_env(accessory["env"], [ "accessories", name, "env" ], findings) if accessory.is_a?(Hash)
|
|
19
|
+
end
|
|
20
|
+
findings
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def scan_env(env, prefix, findings)
|
|
26
|
+
return unless env.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
clear = env["clear"]
|
|
29
|
+
return unless clear.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
clear.each do |key, _value|
|
|
32
|
+
next unless key.is_a?(String) && key.match?(SECRET_KEY_PATTERN)
|
|
33
|
+
|
|
34
|
+
findings << finding(
|
|
35
|
+
message: "env.clear contains `#{key}` which looks like a secret; move it to env.secret + .kamal/secrets",
|
|
36
|
+
line: context.line_for(prefix + [ "clear", key ])
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Lint.registry.register(SecretInEnvClear)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
module Checks
|
|
6
|
+
class SecretNotDeclared < Check
|
|
7
|
+
id "secret-not-declared"
|
|
8
|
+
severity :error
|
|
9
|
+
since "2.0.0"
|
|
10
|
+
title "env.secret references a key not declared in .kamal/secrets"
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
findings = []
|
|
14
|
+
declared = context.secrets
|
|
15
|
+
|
|
16
|
+
referenced_secret_keys.each do |path, name|
|
|
17
|
+
next if declared.include?(name)
|
|
18
|
+
|
|
19
|
+
findings << finding(
|
|
20
|
+
message: "env.secret references `#{name}` but it isn't declared in .kamal/secrets",
|
|
21
|
+
line: context.line_for(path)
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
findings
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def referenced_secret_keys
|
|
31
|
+
refs = []
|
|
32
|
+
collect_secrets(parsed["env"], [ "env" ], refs)
|
|
33
|
+
(parsed["accessories"] || {}).each do |name, accessory|
|
|
34
|
+
next unless accessory.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
collect_secrets(accessory["env"], [ "accessories", name, "env" ], refs)
|
|
37
|
+
end
|
|
38
|
+
refs
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def collect_secrets(env_block, prefix, refs)
|
|
42
|
+
return unless env_block.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
list = env_block["secret"]
|
|
45
|
+
return unless list.is_a?(Array)
|
|
46
|
+
|
|
47
|
+
list.each_with_index do |name, idx|
|
|
48
|
+
next unless name.is_a?(String)
|
|
49
|
+
|
|
50
|
+
refs << [ prefix + [ "secret", idx.to_s ], name ]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
Lint.registry.register(SecretNotDeclared)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
module Checks
|
|
6
|
+
class SslWithoutHost < Check
|
|
7
|
+
id "ssl-without-host"
|
|
8
|
+
severity :error
|
|
9
|
+
since "2.0.0"
|
|
10
|
+
title "SSL enabled without a host configured"
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
proxy = parsed["proxy"]
|
|
14
|
+
return [] unless proxy.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
ssl_enabled = proxy["ssl"] == true
|
|
17
|
+
return [] unless ssl_enabled
|
|
18
|
+
|
|
19
|
+
host = proxy["host"]
|
|
20
|
+
hosts = proxy["hosts"]
|
|
21
|
+
|
|
22
|
+
host_set = (host.is_a?(String) && !host.empty?) ||
|
|
23
|
+
(hosts.is_a?(Array) && hosts.any? { |h| h.is_a?(String) && !h.empty? })
|
|
24
|
+
|
|
25
|
+
return [] if host_set
|
|
26
|
+
|
|
27
|
+
[ finding(
|
|
28
|
+
message: "proxy.ssl: true requires `host:` (or `hosts:`) to be set for automatic Let's Encrypt provisioning",
|
|
29
|
+
line: context.line_for([ "proxy", "ssl" ]) || context.line_for([ "proxy" ])
|
|
30
|
+
) ]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Lint.registry.register(SslWithoutHost)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
module Checks
|
|
6
|
+
class TraefikLegacyKeys < Check
|
|
7
|
+
id "traefik-legacy-keys"
|
|
8
|
+
severity :warning
|
|
9
|
+
since "2.0.0"
|
|
10
|
+
autofixable true
|
|
11
|
+
title "Kamal 1.x `traefik:` keys present (use `proxy:` in Kamal 2+)"
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
return [] unless parsed.key?("traefik")
|
|
15
|
+
|
|
16
|
+
[ finding(
|
|
17
|
+
message: "`traefik:` block is Kamal 1.x legacy and is ignored in Kamal 2+; use `proxy:` instead",
|
|
18
|
+
line: context.line_for([ "traefik" ]),
|
|
19
|
+
autofix: method(:apply_fix)
|
|
20
|
+
) ]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def apply_fix(ctx)
|
|
24
|
+
file = ctx.file_for_finding
|
|
25
|
+
text = File.read(file)
|
|
26
|
+
parsed = YAML.safe_load(text, aliases: true) || {}
|
|
27
|
+
return false unless parsed.is_a?(Hash) && parsed.key?("traefik")
|
|
28
|
+
|
|
29
|
+
traefik = parsed.delete("traefik") || {}
|
|
30
|
+
proxy = parsed["proxy"] || {}
|
|
31
|
+
|
|
32
|
+
# Conservative translation:
|
|
33
|
+
# - traefik.host → proxy.host
|
|
34
|
+
# - traefik.ssl_redirect → proxy.ssl: true (Kamal 2 handles SSL via proxy.ssl)
|
|
35
|
+
# - traefik.args.entryPoints.address: ":<port>" → proxy.app_port: <port>
|
|
36
|
+
if (host = traefik["host"]) && !proxy["host"]
|
|
37
|
+
proxy["host"] = host
|
|
38
|
+
end
|
|
39
|
+
if traefik["ssl_redirect"] == true || traefik.dig("args", "entrypoints.websecure.address")
|
|
40
|
+
proxy["ssl"] = true unless proxy.key?("ssl")
|
|
41
|
+
end
|
|
42
|
+
if (addr = traefik.dig("args", "entrypoints.web.address")) && addr.is_a?(String)
|
|
43
|
+
port = addr.scan(/\d+/).first
|
|
44
|
+
proxy["app_port"] = port.to_i if port && !proxy.key?("app_port")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
parsed["proxy"] = proxy unless proxy.empty?
|
|
48
|
+
File.write(file, YAML.dump(parsed))
|
|
49
|
+
true
|
|
50
|
+
rescue => _e
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
Lint.registry.register(TraefikLegacyKeys)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Kamal
|
|
6
|
+
module Lint
|
|
7
|
+
class CLI < Thor
|
|
8
|
+
def self.exit_on_failure?
|
|
9
|
+
true
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_option :config_file, aliases: "-c", type: :string,
|
|
13
|
+
default: "config/deploy.yml",
|
|
14
|
+
desc: "Path to the Kamal deploy.yml"
|
|
15
|
+
class_option :destination, aliases: "-d", type: :string,
|
|
16
|
+
desc: "Destination override (e.g. production → config/deploy.production.yml)"
|
|
17
|
+
class_option :format, aliases: "-f", type: :string, default: "human",
|
|
18
|
+
enum: %w[human json github],
|
|
19
|
+
desc: "Output format"
|
|
20
|
+
class_option :fail_on, type: :string, default: "warning",
|
|
21
|
+
enum: %w[error warning info],
|
|
22
|
+
desc: "Minimum severity that causes a non-zero exit code"
|
|
23
|
+
class_option :fix, type: :boolean, default: false,
|
|
24
|
+
desc: "Apply safe autofixes in-place"
|
|
25
|
+
class_option :"kamal-version", type: :string,
|
|
26
|
+
desc: "Override detected Kamal version"
|
|
27
|
+
class_option :"include-kamal-errors", type: :boolean, default: false,
|
|
28
|
+
desc: "Also surface errors from Kamal's own loader (off by default; use `kamal config` for that)"
|
|
29
|
+
class_option :no_color, type: :boolean, default: false,
|
|
30
|
+
desc: "Disable colored output"
|
|
31
|
+
|
|
32
|
+
desc "lint", "Lint the Kamal deploy.yml (default command)"
|
|
33
|
+
def lint
|
|
34
|
+
runner = Runner.new(
|
|
35
|
+
config_file: options[:config_file],
|
|
36
|
+
destination: options[:destination],
|
|
37
|
+
kamal_version: options[:"kamal-version"],
|
|
38
|
+
fix: options[:fix],
|
|
39
|
+
include_kamal_errors: options[:"include-kamal-errors"]
|
|
40
|
+
)
|
|
41
|
+
result = runner.call
|
|
42
|
+
formatter = build_formatter(options[:format], options[:no_color])
|
|
43
|
+
formatter.render(result)
|
|
44
|
+
formatter.render_fix_summary(result) if options[:fix]
|
|
45
|
+
exit(result.exit_code(fail_on: options[:fail_on].to_sym))
|
|
46
|
+
rescue ConfigNotFoundError => e
|
|
47
|
+
warn "kamal-lint: #{e.message}"
|
|
48
|
+
exit(2)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
default_task :lint
|
|
52
|
+
|
|
53
|
+
desc "list-checks", "List all registered checks"
|
|
54
|
+
def list_checks
|
|
55
|
+
registry = Lint.registry
|
|
56
|
+
format = options[:format]
|
|
57
|
+
|
|
58
|
+
if format == "json"
|
|
59
|
+
require "json"
|
|
60
|
+
payload = registry.all.map do |check|
|
|
61
|
+
{
|
|
62
|
+
id: check.id,
|
|
63
|
+
severity: check.severity.to_s,
|
|
64
|
+
title: check.title,
|
|
65
|
+
since: check.since,
|
|
66
|
+
until_version: check.until_version,
|
|
67
|
+
autofixable: check.autofixable
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
puts JSON.pretty_generate(payload)
|
|
71
|
+
else
|
|
72
|
+
puts "#{"ID".ljust(38)} #{"SEVERITY".ljust(9)} #{"SINCE".ljust(8)} TITLE"
|
|
73
|
+
puts "-" * 110
|
|
74
|
+
registry.all.each do |check|
|
|
75
|
+
line = [
|
|
76
|
+
check.id.to_s.ljust(38),
|
|
77
|
+
check.severity.to_s.ljust(9),
|
|
78
|
+
(check.since || "—").to_s.ljust(8),
|
|
79
|
+
check.title.to_s
|
|
80
|
+
].join(" ")
|
|
81
|
+
line += " (autofixable)" if check.autofixable
|
|
82
|
+
puts line
|
|
83
|
+
end
|
|
84
|
+
puts
|
|
85
|
+
puts "Total: #{registry.all.size} checks"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
desc "version", "Show version"
|
|
90
|
+
def version
|
|
91
|
+
puts "kamal-lint #{Kamal::Lint::VERSION}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
map "--version" => :version
|
|
95
|
+
map "-v" => :version
|
|
96
|
+
|
|
97
|
+
no_commands do
|
|
98
|
+
def build_formatter(name, no_color)
|
|
99
|
+
klass = FORMATTERS.fetch(name) { raise ArgumentError, "unknown formatter: #{name}" }
|
|
100
|
+
if klass == Formatters::Human
|
|
101
|
+
klass.new(color: !no_color && $stdout.tty?)
|
|
102
|
+
else
|
|
103
|
+
klass.new
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
Finding = Struct.new(
|
|
6
|
+
:check_id,
|
|
7
|
+
:severity,
|
|
8
|
+
:message,
|
|
9
|
+
:file,
|
|
10
|
+
:line,
|
|
11
|
+
:column,
|
|
12
|
+
:autofix,
|
|
13
|
+
keyword_init: true
|
|
14
|
+
) do
|
|
15
|
+
def autofixable?
|
|
16
|
+
!autofix.nil?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_h
|
|
20
|
+
{
|
|
21
|
+
check: check_id,
|
|
22
|
+
severity: severity.to_s,
|
|
23
|
+
message: message,
|
|
24
|
+
file: file,
|
|
25
|
+
line: line,
|
|
26
|
+
column: column,
|
|
27
|
+
autofixable: autofixable?
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Lint
|
|
5
|
+
module Formatters
|
|
6
|
+
# GitHub Actions workflow command output.
|
|
7
|
+
# See: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
|
|
8
|
+
class Github
|
|
9
|
+
LEVEL = {
|
|
10
|
+
error: "error",
|
|
11
|
+
warning: "warning",
|
|
12
|
+
info: "notice"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(io: $stdout)
|
|
16
|
+
@io = io
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render(result)
|
|
20
|
+
result.findings.each do |finding|
|
|
21
|
+
level = LEVEL[finding.severity] || "notice"
|
|
22
|
+
attrs = {
|
|
23
|
+
file: finding.file,
|
|
24
|
+
line: finding.line,
|
|
25
|
+
col: finding.column,
|
|
26
|
+
title: "kamal-lint: #{finding.check_id}"
|
|
27
|
+
}.compact
|
|
28
|
+
|
|
29
|
+
attr_str = attrs.map { |k, v| "#{k}=#{escape_property(v.to_s)}" }.join(",")
|
|
30
|
+
message = escape_message(finding.message)
|
|
31
|
+
@io.puts "::#{level} #{attr_str}::#{message}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def render_fix_summary(result)
|
|
36
|
+
return if result.fixed.empty?
|
|
37
|
+
|
|
38
|
+
result.fixed.each do |finding|
|
|
39
|
+
@io.puts "::notice file=#{finding.file},title=kamal-lint autofix::Fixed #{finding.check_id}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def escape_message(value)
|
|
46
|
+
value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def escape_property(value)
|
|
50
|
+
value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A").gsub(":", "%3A").gsub(",", "%2C")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|