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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +190 -0
  5. data/action.yml +61 -0
  6. data/bin/kamal-lint +7 -0
  7. data/lib/kamal/lint/check.rb +90 -0
  8. data/lib/kamal/lint/checks/accessory_image_latest.rb +43 -0
  9. data/lib/kamal/lint/checks/accessory_placement_missing.rb +49 -0
  10. data/lib/kamal/lint/checks/accessory_role_undefined.rb +43 -0
  11. data/lib/kamal/lint/checks/boot_limit_exceeds_hosts.rb +34 -0
  12. data/lib/kamal/lint/checks/builder_registry_secret_undeclared.rb +42 -0
  13. data/lib/kamal/lint/checks/empty_web_role.rb +37 -0
  14. data/lib/kamal/lint/checks/image_registry_mismatch.rb +39 -0
  15. data/lib/kamal/lint/checks/kamal_secrets_not_gitignored.rb +56 -0
  16. data/lib/kamal/lint/checks/missing_proxy_healthcheck.rb +27 -0
  17. data/lib/kamal/lint/checks/missing_service_name.rb +46 -0
  18. data/lib/kamal/lint/checks/registry_without_explicit_server.rb +37 -0
  19. data/lib/kamal/lint/checks/role_hosts_empty.rb +35 -0
  20. data/lib/kamal/lint/checks/secret_in_env_clear.rb +45 -0
  21. data/lib/kamal/lint/checks/secret_not_declared.rb +58 -0
  22. data/lib/kamal/lint/checks/ssl_without_host.rb +37 -0
  23. data/lib/kamal/lint/checks/traefik_legacy_keys.rb +58 -0
  24. data/lib/kamal/lint/cli.rb +109 -0
  25. data/lib/kamal/lint/finding.rb +32 -0
  26. data/lib/kamal/lint/formatters/github.rb +55 -0
  27. data/lib/kamal/lint/formatters/human.rb +118 -0
  28. data/lib/kamal/lint/formatters/json.rb +38 -0
  29. data/lib/kamal/lint/kamal_version.rb +62 -0
  30. data/lib/kamal/lint/loader.rb +175 -0
  31. data/lib/kamal/lint/registry.rb +32 -0
  32. data/lib/kamal/lint/runner.rb +102 -0
  33. data/lib/kamal/lint/secrets_file.rb +29 -0
  34. data/lib/kamal/lint/servers_helper.rb +60 -0
  35. data/lib/kamal/lint/version.rb +7 -0
  36. data/lib/kamal/lint.rb +63 -0
  37. metadata +177 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fbb47a4aa7557a495d992e752262116d9b3617baa4918f8926be85fa3263f2c1
4
+ data.tar.gz: 5ae854761838ac0bb597aa7318126d94c6cbe40a3ae6ae7e1c28da47eedbe499
5
+ SHA512:
6
+ metadata.gz: f4651938af9157e4a519a4a76b03f57b126821b1d0849d7d240a2f018e446898570b50b85f208dc0238bbfede41340ba8000ba995dbfc51e3fddf5bb4273dcfe
7
+ data.tar.gz: 74dd99df9454c440074df74d3110c025eafc0b7f7b6e6917d8964b65363ab8ad69aff2c5fda62b4789c1cf9536e6ceca28bc04069a7d6c7532191ae6d08024f1
data/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+ - `--include-kamal-errors` flag to opt-in to surfacing errors from Kamal's own loader (off by default; use `kamal config` for parse-time checks).
12
+
13
+ ### Fixed
14
+ - `image-registry-mismatch` no longer false-positives when `registry.server` is set to Docker Hub (`docker.io`, `index.docker.io`, `registry.hub.docker.com`) and the image lacks an explicit registry prefix — Docker Hub resolves unprefixed images automatically.
15
+
16
+ ### Internal
17
+ - Renamed `LICENSE` → `MIT-LICENSE` to match Kamal's convention.
18
+ - Added `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `.ruby-version`, `bin/release`.
19
+ - Added `Release` GitHub Actions workflow for tag-triggered publishing via RubyGems trusted publishing (OIDC).
20
+ - Added `actionlint` and `zizmor` workflow security audits in CI.
21
+ - Added `dependabot.yml` for weekly bundler + GitHub Actions updates.
22
+ - Added PR template and issue templates (bug report, new check proposal).
23
+
24
+ ## [0.1.0] - 2026-05-11
25
+
26
+ ### Added
27
+ - Initial release.
28
+ - 16 checks across reference integrity, coherence, and smells:
29
+ - `secret-not-declared`
30
+ - `accessory-role-undefined`
31
+ - `proxy-host-not-in-role`
32
+ - `image-registry-mismatch`
33
+ - `builder-registry-secret-undeclared`
34
+ - `ssl-without-host`
35
+ - `empty-web-role`
36
+ - `traefik-legacy-keys` (autofixable)
37
+ - `boot-limit-exceeds-hosts`
38
+ - `accessory-host-undefined`
39
+ - `missing-service-name` (autofixable)
40
+ - `kamal-secrets-not-gitignored` (autofixable)
41
+ - `secret-in-env-clear`
42
+ - `missing-proxy-healthcheck`
43
+ - `accessory-image-latest`
44
+ - `registry-without-explicit-server`
45
+ - Three output formatters: `human`, `json`, `github` (GitHub Actions annotations).
46
+ - `--fix` for the safe autofix subset.
47
+ - `-d/--destination` for linting destination override files.
48
+ - Auto-detection of installed Kamal version with version-gated checks.
49
+ - `kamal-lint` GitHub Action (composite) for one-line CI integration.
50
+
51
+ [Unreleased]: https://github.com/davafons/kamal-lint/compare/v0.1.0...HEAD
52
+ [0.1.0]: https://github.com/davafons/kamal-lint/releases/tag/v0.1.0
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Afonso
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,190 @@
1
+ <h1 align="center">kamal-lint</h1>
2
+
3
+ <p align="center">
4
+ <a href="https://rubygems.org/gems/kamal-lint"><img src="https://img.shields.io/gem/v/kamal-lint" alt="Gem Version"></a>
5
+ <a href="https://github.com/davafons/kamal-lint/actions/workflows/ci.yml"><img src="https://github.com/davafons/kamal-lint/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
6
+ <a href="https://github.com/davafons/kamal-lint/blob/main/MIT-LICENSE"><img src="https://img.shields.io/github/license/davafons/kamal-lint" alt="License"></a>
7
+ <a href="https://rubygems.org/gems/kamal-lint"><img src="https://img.shields.io/gem/dt/kamal-lint" alt="Downloads"></a>
8
+ </p>
9
+
10
+ Static linter for [Kamal](https://kamal-deploy.org) `config/deploy.yml`. Catches cross-section bugs and smells that Kamal itself silently allows — undeclared secrets, accessory/role mismatches, registry inconsistencies, and more — before a single SSH connection happens.
11
+
12
+ ```
13
+ $ bundle exec kamal-lint
14
+ kamal-lint 0.1.0 · kamal 2.11.0 detected
15
+ config: config/deploy.yml
16
+
17
+ ✖ error config/deploy.yml:9
18
+ env.secret references `RAILS_MASTER_KEY` but it isn't declared in .kamal/secrets
19
+ [secret-not-declared]
20
+
21
+ ⚠ warning config/deploy.yml:18 (autofixable)
22
+ `traefik:` block is Kamal 1.x legacy and is ignored in Kamal 2+; use `proxy:` instead
23
+ [traefik-legacy-keys]
24
+
25
+ Summary: 1 error, 1 warning, 1 autofixable
26
+ ```
27
+
28
+ ## Why
29
+
30
+ Kamal's own loader only checks "does this YAML parse into my schema?" It happily accepts a config that references secrets you never declared, points at registries you don't use, names roles that don't exist, or still uses Kamal 1.x `traefik:` keys. By the time you find out, you've already shipped a broken deploy.
31
+
32
+ `kamal-lint` runs in CI (or pre-commit) and catches these before they hit production.
33
+
34
+ ## Install
35
+
36
+ Add to your project's `Gemfile`:
37
+
38
+ ```ruby
39
+ group :development, :test do
40
+ gem "kamal-lint", require: false
41
+ end
42
+ ```
43
+
44
+ Then:
45
+
46
+ ```bash
47
+ bundle install
48
+ bundle exec kamal-lint
49
+ ```
50
+
51
+ Or install globally:
52
+
53
+ ```bash
54
+ gem install kamal-lint
55
+ kamal-lint
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ ```
61
+ bundle exec kamal-lint [OPTIONS]
62
+
63
+ -c, --config-file PATH Path to deploy.yml (default: config/deploy.yml)
64
+ -d, --destination NAME Lint with destination override applied
65
+ (e.g. -d production → config/deploy.production.yml)
66
+ -f, --format FORMAT human (default) | json | github
67
+ --fail-on LEVEL error | warning (default) | info
68
+ --fix Apply safe autofixes in-place
69
+ --kamal-version VER Override detected Kamal version
70
+ --include-kamal-errors Also surface errors from Kamal's own loader
71
+ (off by default; use `kamal config` for that)
72
+ --no-color Disable colored output
73
+ --list-checks Print all registered checks
74
+ --version Print kamal-lint version
75
+ ```
76
+
77
+ ### Exit codes
78
+
79
+ | Code | Meaning |
80
+ |------|---------|
81
+ | `0` | No findings at or above `--fail-on` severity |
82
+ | `1` | Findings present at or above `--fail-on` severity |
83
+ | `2` | Config file not found / unreadable |
84
+
85
+ ## Checks
86
+
87
+ | ID | Severity | Autofixable | What it catches |
88
+ |---|---|---|---|
89
+ | `secret-not-declared` | error | | `env.secret` references a key absent from `.kamal/secrets` |
90
+ | `accessory-role-undefined` | error | | accessory `roles:` lists a role not in `servers` |
91
+ | `role-hosts-empty` | error | | a role under `servers:` has no hosts (silent no-op deploy) |
92
+ | `image-registry-mismatch` | error | | `image:` registry prefix ≠ `builder.registry.server` |
93
+ | `builder-registry-secret-undeclared` | error | | registry username/password references undeclared secret |
94
+ | `ssl-without-host` | error | | `proxy.ssl: true` without `host:` (Let's Encrypt won't work) |
95
+ | `empty-web-role` | error | | `servers:` empty or has no hosts in any role |
96
+ | `accessory-placement-missing` | error | | accessory has no `host`/`hosts`/`roles` declared |
97
+ | `missing-service-name` | error | ✓ | `service:` not set |
98
+ | `traefik-legacy-keys` | warning | ✓ | Kamal 1.x `traefik:` block (silently ignored by Kamal 2+) |
99
+ | `boot-limit-exceeds-hosts` | warning | | `boot.limit` greater than the number of hosts |
100
+ | `kamal-secrets-not-gitignored` | warning | ✓ | `.kamal/secrets` exists but isn't gitignored |
101
+ | `secret-in-env-clear` | warning | | `env.clear` value looks like a secret (`*_KEY`/`*_TOKEN`/`*_SECRET`/etc.) |
102
+ | `missing-proxy-healthcheck` | warning | | `proxy:` block with no `healthcheck:` (no zero-downtime guarantee) |
103
+ | `accessory-image-latest` | warning | | accessory pinned to `:latest` or unpinned |
104
+ | `registry-without-explicit-server` | warning | | `registry.server` missing; image silently defaults to Docker Hub |
105
+
106
+ Run `kamal-lint list-checks` for the same table in your terminal, including the Kamal version range each check applies to.
107
+
108
+ ## Autofix
109
+
110
+ `--fix` rewrites your config in-place for the safe subset:
111
+
112
+ - `traefik-legacy-keys` → translates `traefik:` to a `proxy:` block (host, ssl, app_port)
113
+ - `missing-service-name` → infers `service:` from the project directory name
114
+ - `kamal-secrets-not-gitignored` → appends `.kamal/secrets` to `.gitignore`
115
+
116
+ ```bash
117
+ bundle exec kamal-lint --fix
118
+ ```
119
+
120
+ > **Heads-up:** autofixes re-serialize your YAML, which means comments and exact formatting are not preserved. Run on a clean working tree so you can review the diff. Anything riskier (e.g. moving env.clear values to env.secret) stays manual on purpose.
121
+
122
+ ## Destination overrides
123
+
124
+ ```bash
125
+ bundle exec kamal-lint -d production
126
+ ```
127
+
128
+ Loads `config/deploy.yml`, deep-merges `config/deploy.production.yml` on top, then runs the full check suite against the merged config. Lets you catch staging/production-only issues without running `kamal deploy`.
129
+
130
+ ## CI / GitHub Actions
131
+
132
+ Drop this into `.github/workflows/lint.yml`:
133
+
134
+ ```yaml
135
+ name: kamal-lint
136
+ on: [push, pull_request]
137
+ jobs:
138
+ kamal-lint:
139
+ runs-on: ubuntu-latest
140
+ steps:
141
+ - uses: actions/checkout@v4
142
+ - uses: ruby/setup-ruby@v1
143
+ with:
144
+ bundler-cache: true
145
+ - run: bundle exec kamal-lint --format=github
146
+ ```
147
+
148
+ The `--format=github` emits GitHub Actions workflow commands so findings show up as inline annotations on the changed file.
149
+
150
+ A composite Action wrapper is also published in this repo at `action.yml`:
151
+
152
+ ```yaml
153
+ - uses: davafons/kamal-lint@v0
154
+ with:
155
+ config-file: config/deploy.yml
156
+ destination: production
157
+ fail-on: warning
158
+ ```
159
+
160
+ ## Kamal version support
161
+
162
+ `kamal-lint` reuses your installed Kamal's loader for the parse layer — it auto-tracks whatever Kamal version is in your `Gemfile.lock`. Each check declares a `since:` / `until_version:` range so the registry filters checks to those applicable to your version.
163
+
164
+ | kamal-lint | supported kamal |
165
+ |---|---|
166
+ | `0.1.x` | `>= 2.0`, `< 3.0` |
167
+
168
+ Override detection with `--kamal-version 2.5.0` when needed (e.g. for CI matrix runs).
169
+
170
+ ## Development
171
+
172
+ ```bash
173
+ bin/setup # bundle install
174
+ bin/test # run the test suite
175
+ bin/console # IRB with kamal-lint loaded
176
+ ```
177
+
178
+ To lint the gem's own source:
179
+
180
+ ```bash
181
+ BUNDLE_ONLY=rubocop bundle exec rubocop
182
+ ```
183
+
184
+ ## Contributing
185
+
186
+ Bug reports and pull requests welcome at [github.com/davafons/kamal-lint](https://github.com/davafons/kamal-lint).
187
+
188
+ ## License
189
+
190
+ MIT. See [MIT-LICENSE](./MIT-LICENSE).
data/action.yml ADDED
@@ -0,0 +1,61 @@
1
+ name: kamal-lint
2
+ description: Lint Kamal config/deploy.yml in GitHub Actions
3
+ author: David Afonso
4
+ branding:
5
+ icon: anchor
6
+ color: blue
7
+
8
+ inputs:
9
+ config-file:
10
+ description: Path to the Kamal deploy.yml
11
+ required: false
12
+ default: config/deploy.yml
13
+ destination:
14
+ description: Destination override (e.g. production, staging)
15
+ required: false
16
+ default: ""
17
+ fail-on:
18
+ description: Minimum severity that causes a non-zero exit code (error, warning, info)
19
+ required: false
20
+ default: warning
21
+ fix:
22
+ description: Apply safe autofixes in-place (true/false)
23
+ required: false
24
+ default: "false"
25
+ kamal-version:
26
+ description: Override detected Kamal version
27
+ required: false
28
+ default: ""
29
+ working-directory:
30
+ description: Working directory to run from
31
+ required: false
32
+ default: "."
33
+
34
+ runs:
35
+ using: composite
36
+ steps:
37
+ - name: Run kamal-lint
38
+ shell: bash
39
+ working-directory: ${{ inputs.working-directory }}
40
+ env:
41
+ KL_CONFIG_FILE: ${{ inputs.config-file }}
42
+ KL_DESTINATION: ${{ inputs.destination }}
43
+ KL_FAIL_ON: ${{ inputs.fail-on }}
44
+ KL_FIX: ${{ inputs.fix }}
45
+ KL_KAMAL_VERSION: ${{ inputs.kamal-version }}
46
+ run: |
47
+ set -euo pipefail
48
+ ARGS=(--format=github --fail-on "$KL_FAIL_ON")
49
+ if [ -n "$KL_CONFIG_FILE" ]; then
50
+ ARGS+=(--config-file "$KL_CONFIG_FILE")
51
+ fi
52
+ if [ -n "$KL_DESTINATION" ]; then
53
+ ARGS+=(--destination "$KL_DESTINATION")
54
+ fi
55
+ if [ -n "$KL_KAMAL_VERSION" ]; then
56
+ ARGS+=(--kamal-version "$KL_KAMAL_VERSION")
57
+ fi
58
+ if [ "$KL_FIX" = "true" ]; then
59
+ ARGS+=(--fix)
60
+ fi
61
+ bundle exec kamal-lint "${ARGS[@]}"
data/bin/kamal-lint ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "kamal/lint"
5
+ require "kamal/lint/cli"
6
+
7
+ Kamal::Lint::CLI.start(ARGV)
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ # Base class for all checks.
6
+ #
7
+ # Subclasses declare their identity and applicable Kamal version range with
8
+ # the DSL methods below and implement `#call(context)` returning an Array of
9
+ # Findings (possibly empty).
10
+ class Check
11
+ class << self
12
+ def id(value = nil)
13
+ @id = value if value
14
+ @id
15
+ end
16
+
17
+ def severity(value = nil)
18
+ @severity = value if value
19
+ @severity || :warning
20
+ end
21
+
22
+ def title(value = nil)
23
+ @title = value if value
24
+ @title
25
+ end
26
+
27
+ def since(value = nil)
28
+ @since = value if value
29
+ @since
30
+ end
31
+
32
+ def until_version(value = nil)
33
+ @until_version = value if value
34
+ @until_version
35
+ end
36
+
37
+ # Mark this check as autofix-capable in the registry listing.
38
+ def autofixable(value = nil)
39
+ @autofixable = value unless value.nil?
40
+ @autofixable || false
41
+ end
42
+
43
+ def applies_to?(kamal_version)
44
+ return true if kamal_version.nil?
45
+
46
+ if @since && Gem::Version.new(kamal_version) < Gem::Version.new(@since)
47
+ return false
48
+ end
49
+ if @until_version && Gem::Version.new(kamal_version) >= Gem::Version.new(@until_version)
50
+ return false
51
+ end
52
+
53
+ true
54
+ end
55
+ end
56
+
57
+ def initialize(context)
58
+ @context = context
59
+ end
60
+
61
+ attr_reader :context
62
+
63
+ def call
64
+ raise NotImplementedError
65
+ end
66
+
67
+ private
68
+
69
+ def finding(message:, line: nil, column: nil, autofix: nil)
70
+ Finding.new(
71
+ check_id: self.class.id,
72
+ severity: self.class.severity,
73
+ message: message,
74
+ file: context.file_for_finding,
75
+ line: line,
76
+ column: column,
77
+ autofix: autofix
78
+ )
79
+ end
80
+
81
+ def parsed
82
+ context.parsed
83
+ end
84
+
85
+ def secrets
86
+ context.secrets
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Checks
6
+ class AccessoryImageLatest < Check
7
+ id "accessory-image-latest"
8
+ severity :warning
9
+ since "2.0.0"
10
+ title "Accessory image pinned to `:latest` (or unpinned)"
11
+
12
+ def call
13
+ accessories = parsed["accessories"]
14
+ return [] unless accessories.is_a?(Hash)
15
+
16
+ findings = []
17
+ accessories.each do |name, accessory|
18
+ next unless accessory.is_a?(Hash)
19
+
20
+ image = accessory["image"]
21
+ next unless image.is_a?(String) && !image.empty?
22
+
23
+ tag = image.split(":", 2)[1]
24
+ if tag.nil?
25
+ findings << finding(
26
+ message: "accessory `#{name}` image `#{image}` has no tag; defaults to `:latest` and updates unexpectedly",
27
+ line: context.line_for([ "accessories", name, "image" ])
28
+ )
29
+ elsif tag == "latest"
30
+ findings << finding(
31
+ message: "accessory `#{name}` image pinned to `:latest`; pin to a specific version to keep deploys reproducible",
32
+ line: context.line_for([ "accessories", name, "image" ])
33
+ )
34
+ end
35
+ end
36
+ findings
37
+ end
38
+ end
39
+
40
+ Lint.registry.register(AccessoryImageLatest)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Checks
6
+ # Accessories must declare placement via at least one of:
7
+ # - host: <single host>
8
+ # - hosts: [<host>, ...]
9
+ # - roles: [<role>, ...]
10
+ # An accessory with none of these has no defined target and Kamal won't
11
+ # deploy it.
12
+ class AccessoryPlacementMissing < Check
13
+ id "accessory-placement-missing"
14
+ severity :error
15
+ since "2.0.0"
16
+ title "Accessory has no `host`, `hosts`, or `roles` declared"
17
+
18
+ def call
19
+ accessories = parsed["accessories"]
20
+ return [] unless accessories.is_a?(Hash)
21
+
22
+ findings = []
23
+ accessories.each do |name, accessory|
24
+ next unless accessory.is_a?(Hash)
25
+
26
+ has_placement = %w[host hosts roles].any? do |k|
27
+ value = accessory[k]
28
+ case value
29
+ when String then !value.empty?
30
+ when Array then value.any?
31
+ else !value.nil?
32
+ end
33
+ end
34
+
35
+ next if has_placement
36
+
37
+ findings << finding(
38
+ message: "accessory `#{name}` has no `host`, `hosts`, or `roles` declared; it will not be deployed anywhere",
39
+ line: context.line_for([ "accessories", name ])
40
+ )
41
+ end
42
+ findings
43
+ end
44
+ end
45
+
46
+ Lint.registry.register(AccessoryPlacementMissing)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Checks
6
+ class AccessoryRoleUndefined < Check
7
+ id "accessory-role-undefined"
8
+ severity :error
9
+ since "2.0.0"
10
+ title "Accessory `roles:` lists a role not defined under `servers`"
11
+
12
+ def call
13
+ findings = []
14
+ accessories = parsed["accessories"]
15
+ return findings unless accessories.is_a?(Hash)
16
+
17
+ defined_roles = ServersHelper.role_names(parsed["servers"])
18
+
19
+ accessories.each do |name, accessory|
20
+ next unless accessory.is_a?(Hash)
21
+
22
+ roles = accessory["roles"]
23
+ next unless roles.is_a?(Array)
24
+
25
+ roles.each_with_index do |role, idx|
26
+ next unless role.is_a?(String)
27
+ next if defined_roles.include?(role)
28
+
29
+ findings << finding(
30
+ message: "accessory `#{name}` references role `#{role}` which is not defined under `servers`",
31
+ line: context.line_for([ "accessories", name, "roles", idx.to_s ])
32
+ )
33
+ end
34
+ end
35
+
36
+ findings
37
+ end
38
+ end
39
+
40
+ Lint.registry.register(AccessoryRoleUndefined)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../servers_helper"
4
+
5
+ module Kamal
6
+ module Lint
7
+ module Checks
8
+ class BootLimitExceedsHosts < Check
9
+ id "boot-limit-exceeds-hosts"
10
+ severity :warning
11
+ since "2.0.0"
12
+ title "`boot.limit` exceeds the number of hosts (no rolling effect)"
13
+
14
+ def call
15
+ boot = parsed["boot"]
16
+ return [] unless boot.is_a?(Hash)
17
+
18
+ limit = boot["limit"]
19
+ return [] unless limit.is_a?(Integer) && limit > 0
20
+
21
+ host_count = ServersHelper.all_hosts(parsed["servers"]).uniq.size
22
+ return [] if host_count == 0 || limit <= host_count
23
+
24
+ [ finding(
25
+ message: "boot.limit is #{limit} but only #{host_count} host(s) are configured; the limit has no effect",
26
+ line: context.line_for([ "boot", "limit" ])
27
+ ) ]
28
+ end
29
+ end
30
+
31
+ Lint.registry.register(BootLimitExceedsHosts)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Checks
6
+ class BuilderRegistrySecretUndeclared < Check
7
+ id "builder-registry-secret-undeclared"
8
+ severity :error
9
+ since "2.0.0"
10
+ title "registry username/password references a secret not declared in .kamal/secrets"
11
+
12
+ def call
13
+ registry = parsed["registry"] || parsed.dig("builder", "registry")
14
+ return [] unless registry.is_a?(Hash)
15
+
16
+ declared = context.secrets
17
+ findings = []
18
+
19
+ %w[username password].each do |key|
20
+ value = registry[key]
21
+ next unless value.is_a?(Array)
22
+
23
+ value.each_with_index do |name, idx|
24
+ next unless name.is_a?(String)
25
+ next if declared.include?(name)
26
+
27
+ findings << finding(
28
+ message: "registry #{key} references secret `#{name}` but it isn't declared in .kamal/secrets",
29
+ line: context.line_for([ "registry", key, idx.to_s ]) ||
30
+ context.line_for([ "builder", "registry", key, idx.to_s ])
31
+ )
32
+ end
33
+ end
34
+
35
+ findings
36
+ end
37
+ end
38
+
39
+ Lint.registry.register(BuilderRegistrySecretUndeclared)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../servers_helper"
4
+
5
+ module Kamal
6
+ module Lint
7
+ module Checks
8
+ class EmptyWebRole < Check
9
+ id "empty-web-role"
10
+ severity :error
11
+ since "2.0.0"
12
+ title "No web role / no hosts to deploy to"
13
+
14
+ def call
15
+ servers = parsed["servers"]
16
+
17
+ all_hosts = ServersHelper.all_hosts(servers)
18
+ if servers.nil? || (servers.is_a?(Enumerable) && servers.empty?)
19
+ return [ finding(
20
+ message: "`servers:` is missing or empty; nothing will be deployed",
21
+ line: context.line_for([ "servers" ]) || 1
22
+ ) ]
23
+ end
24
+
25
+ return [] unless all_hosts.empty?
26
+
27
+ [ finding(
28
+ message: "no hosts declared under any role in `servers:`; nothing will be deployed",
29
+ line: context.line_for([ "servers" ]) || 1
30
+ ) ]
31
+ end
32
+ end
33
+
34
+ Lint.registry.register(EmptyWebRole)
35
+ end
36
+ end
37
+ end