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
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,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
|