kamal-lint 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbb47a4aa7557a495d992e752262116d9b3617baa4918f8926be85fa3263f2c1
4
- data.tar.gz: 5ae854761838ac0bb597aa7318126d94c6cbe40a3ae6ae7e1c28da47eedbe499
3
+ metadata.gz: 10d72a6911e515326e690ea6c0c65c74742378edf3f0c0229d725b9f623f75db
4
+ data.tar.gz: 63de632c1ff0ac7a21e180dd38e638464de147b81f69ea48654ddca5e07e7b28
5
5
  SHA512:
6
- metadata.gz: f4651938af9157e4a519a4a76b03f57b126821b1d0849d7d240a2f018e446898570b50b85f208dc0238bbfede41340ba8000ba995dbfc51e3fddf5bb4273dcfe
7
- data.tar.gz: 74dd99df9454c440074df74d3110c025eafc0b7f7b6e6917d8964b65363ab8ad69aff2c5fda62b4789c1cf9536e6ceca28bc04069a7d6c7532191ae6d08024f1
6
+ metadata.gz: ae304091aa74c7cbd9f3f85e455029cd85cb0bdfa0f3c26d49764e347733d7363c15fef2eb1a01c70df96521e7e8e868a0f17f92c9402e2996a37d9f0dc3cc5c
7
+ data.tar.gz: 66568b9b1bcfb8a0287a5e083e97a24520343366a9f6229f428df0715a341da15db5081efc4e8013444baa74cdc8e6f301e03a928ce39db6c994d6eb7e10fa69
data/CHANGELOG.md CHANGED
@@ -1,52 +1,5 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project will be documented in this file.
3
+ Release notes are auto-generated from merged commits and pull requests, and live with each tagged release:
4
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
5
+ <https://github.com/davafons/kamal-lint/releases>
data/README.md CHANGED
@@ -7,184 +7,129 @@
7
7
  <a href="https://rubygems.org/gems/kamal-lint"><img src="https://img.shields.io/gem/dt/kamal-lint" alt="Downloads"></a>
8
8
  </p>
9
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.
10
+ Static linter for [Kamal](https://kamal-deploy.org) `config/deploy.yml`. Catches missing secrets, role/registry mismatches, and proxy footguns that Kamal silently allows.
11
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.
12
+ <p align="center">
13
+ <img src="docs/preview.svg" alt="kamal-lint output sample" width="760">
14
+ </p>
33
15
 
34
16
  ## Install
35
17
 
36
- Add to your project's `Gemfile`:
37
-
38
18
  ```ruby
19
+ # Gemfile
39
20
  group :development, :test do
40
21
  gem "kamal-lint", require: false
41
22
  end
42
23
  ```
43
24
 
44
- Then:
45
-
46
25
  ```bash
47
- bundle install
48
26
  bundle exec kamal-lint
49
27
  ```
50
28
 
51
- Or install globally:
29
+ ## Usage
30
+
31
+ **Default: lint base + every destination override at once.**
52
32
 
53
33
  ```bash
54
- gem install kamal-lint
55
- kamal-lint
34
+ bundle exec kamal-lint
56
35
  ```
57
36
 
58
- ## Usage
37
+ Auto-discovers `config/deploy.*.yml` files next to your base `config/deploy.yml` and lints each (base alone, then each destination merged onto base). Output groups findings by destination — bugs that live in a `deploy.production.yml` override show up *only* under `[production]`:
59
38
 
60
39
  ```
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
40
+ [base] config/deploy.yml
41
+ ✓ No issues found.
78
42
 
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
43
+ [production] config/deploy.production.yml
44
+ ⚠ warning ...
45
+ `traefik:` block is Kamal 1.x legacy ...
86
46
 
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`
47
+ [staging] config/deploy.staging.yml
48
+ ✓ No issues found.
115
49
 
116
- ```bash
117
- bundle exec kamal-lint --fix
50
+ Summary: 1 warning across 3 configs
118
51
  ```
119
52
 
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.
53
+ Exits `1` if any findings are at or above `--fail-on` (default: `warning`), `0` otherwise.
121
54
 
122
- ## Destination overrides
55
+ **Narrow to a single destination:**
123
56
 
124
57
  ```bash
125
58
  bundle exec kamal-lint -d production
126
59
  ```
127
60
 
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
61
+ Useful in CI matrix jobs or when debugging one destination. Skips auto-discovery; only lints `deploy.yml + deploy.production.yml`.
131
62
 
132
- Drop this into `.github/workflows/lint.yml`:
63
+ **Other knobs:**
133
64
 
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
65
+ ```bash
66
+ bundle exec kamal-lint -c infra/deploy.yml # non-default config path
67
+ bundle exec kamal-lint list-checks # show every registered check
68
+ bundle exec kamal-lint --format=json # machine-readable output
69
+ bundle exec kamal-lint --include-kamal-errors # also surface Kamal's loader errors
146
70
  ```
147
71
 
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`:
72
+ **In CI:**
151
73
 
152
74
  ```yaml
153
- - uses: davafons/kamal-lint@v0
75
+ - uses: davafons/kamal-lint@v0.1.0
154
76
  with:
155
- config-file: config/deploy.yml
156
- destination: production
157
77
  fail-on: warning
158
78
  ```
159
79
 
160
- ## Kamal version support
80
+ `--format=github` is set automatically so findings show as inline annotations in the PR view. By default the action lints all destinations; set `destination: production` to narrow.
161
81
 
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` |
82
+ ## Checks
167
83
 
168
- Override detection with `--kamal-version 2.5.0` when needed (e.g. for CI matrix runs).
84
+ | ID | Severity | What it catches |
85
+ |---|---|---|
86
+ | `secret-not-declared` | error | `env.secret` (top-level or per-accessory) references a key that isn't declared in `.kamal/secrets`. Kamal would fail at deploy time. |
87
+ | `accessory-role-undefined` | error | An accessory's `roles:` lists a role name that isn't defined under `servers:`. The accessory won't deploy to anything. |
88
+ | `role-hosts-empty` | error | A role under `servers:` has no hosts. Deploys to that role silently no-op. |
89
+ | `image-registry-mismatch` | error | `image:` doesn't include the prefix of `registry.server`. Kamal would push/pull from the wrong registry. (Docker Hub is exempt — unprefixed images resolve there automatically.) |
90
+ | `builder-registry-secret-undeclared` | error | `registry.username` or `registry.password` references a secret name that isn't in `.kamal/secrets`. |
91
+ | `ssl-without-host` | error | `proxy.ssl: true` without a `host:` (or `hosts:`). Let's Encrypt provisioning has nothing to issue against. |
92
+ | `empty-web-role` | error | `servers:` is empty or every role has no hosts. Nothing would be deployed. |
93
+ | `accessory-placement-missing` | error | An accessory has none of `host`, `hosts`, or `roles` declared, so Kamal has no idea where to put it. |
94
+ | `missing-service-name` | error | `service:` is not set. Kamal can't name the container. |
95
+ | `traefik-legacy-keys` | warning | A `traefik:` block is still present. Kamal 2+ uses `proxy:` and silently ignores the old block. |
96
+ | `boot-limit-exceeds-hosts` | warning | `boot.limit` is greater than the total number of hosts, so the rolling-deploy limit has no effect. |
97
+ | `kamal-secrets-not-gitignored` | warning | `.kamal/secrets` exists in the repo but isn't matched by `.gitignore`. Real credentials are one `git add .` away from a commit. |
98
+ | `secret-in-env-clear` | warning | A key in `env.clear` looks like a secret (`*_KEY`, `*_TOKEN`, `*_SECRET`, `*PASSWORD*`). Move it to `env.secret` + `.kamal/secrets`. |
99
+ | `missing-proxy-healthcheck` | warning | The `proxy:` block has no `healthcheck:`. Kamal-proxy can't verify a new release before cutting traffic — zero-downtime deploys may fail. |
100
+ | `accessory-image-latest` | warning | An accessory's `image:` is pinned to `:latest` (or has no tag). Updates can change unexpectedly between deploys. |
101
+ | `registry-without-explicit-server` | warning | `registry` is set but `registry.server` isn't. Kamal silently defaults to Docker Hub. |
102
+ | `kamal-parse-error` | error | *Opt-in.* Surfaces errors from Kamal's own loader. Enable with `--include-kamal-errors`. Useful in CI as a complement to `kamal config`. |
103
+
104
+ Reasoning behind each finding is also in the message text — paste a finding into search and you'll usually land on the relevant Kamal doc.
105
+
106
+ ## Flags
169
107
 
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
108
+ ```
109
+ -c, --config-file PATH config/deploy.yml
110
+ -d, --destination NAME lint deploy.<name>.yml merged onto base
111
+ -f, --format FORMAT human | json | github
112
+ --fail-on LEVEL error | warning | info
113
+ --kamal-version VER override detected Kamal version
114
+ --include-kamal-errors also surface Kamal's loader errors
176
115
  ```
177
116
 
178
- To lint the gem's own source:
117
+ Exit codes: `0` clean · `1` findings at/above `--fail-on` · `2` config missing.
179
118
 
180
- ```bash
181
- BUNDLE_ONLY=rubocop bundle exec rubocop
182
- ```
119
+ ## Kamal versions
183
120
 
184
- ## Contributing
121
+ | kamal-lint | kamal | tested against |
122
+ |---|---|---|
123
+ | `0.1.x` | `>= 2.0`, `< 3.0` | latest 2.x |
185
124
 
186
- Bug reports and pull requests welcome at [github.com/davafons/kamal-lint](https://github.com/davafons/kamal-lint).
125
+ `kamal-lint` reuses your installed Kamal's loader, so it auto-tracks whatever's in your `Gemfile.lock`. Older 2.x versions likely work but aren't covered by CI — if you hit a bug on an older Kamal, please [open an issue](https://github.com/davafons/kamal-lint/issues). Override the detected version with `--kamal-version 2.5.0` for matrix runs.
187
126
 
188
- ## License
127
+ ## Development
128
+
129
+ ```bash
130
+ bin/setup # install
131
+ bin/test # run tests
132
+ bin/console # IRB with kamal-lint loaded
133
+ ```
189
134
 
190
- MIT. See [MIT-LICENSE](./MIT-LICENSE).
135
+ Contributions: [CONTRIBUTING.md](./CONTRIBUTING.md) · Security: [SECURITY.md](./SECURITY.md) · License: [MIT](./MIT-LICENSE).
data/action.yml CHANGED
@@ -58,4 +58,11 @@ runs:
58
58
  if [ "$KL_FIX" = "true" ]; then
59
59
  ARGS+=(--fix)
60
60
  fi
61
- bundle exec kamal-lint "${ARGS[@]}"
61
+
62
+ # Prefer bundler if there's a Gemfile referencing kamal-lint;
63
+ # fall back to a globally-installed gem otherwise.
64
+ if [ -f Gemfile ] && bundle list 2>/dev/null | grep -q '^ \* kamal-lint '; then
65
+ bundle exec kamal-lint "${ARGS[@]}"
66
+ else
67
+ kamal-lint "${ARGS[@]}"
68
+ fi
@@ -34,12 +34,6 @@ module Kamal
34
34
  @until_version
35
35
  end
36
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
37
  def applies_to?(kamal_version)
44
38
  return true if kamal_version.nil?
45
39
 
@@ -66,25 +60,20 @@ module Kamal
66
60
 
67
61
  private
68
62
 
69
- def finding(message:, line: nil, column: nil, autofix: nil)
63
+ def finding(message:, line: nil, column: nil)
70
64
  Finding.new(
71
65
  check_id: self.class.id,
72
66
  severity: self.class.severity,
73
67
  message: message,
74
68
  file: context.file_for_finding,
75
69
  line: line,
76
- column: column,
77
- autofix: autofix
70
+ column: column
78
71
  )
79
72
  end
80
73
 
81
74
  def parsed
82
75
  context.parsed
83
76
  end
84
-
85
- def secrets
86
- context.secrets
87
- end
88
77
  end
89
78
  end
90
79
  end
@@ -14,6 +14,9 @@ module Kamal
14
14
  # configured registry is Docker Hub under any of its canonical names.
15
15
  DOCKER_HUB_HOSTS = %w[docker.io index.docker.io registry.hub.docker.com].freeze
16
16
 
17
+ # A leading path segment is treated as a registry host when it contains
18
+ # a `.` or `:` (e.g. `ghcr.io`, `localhost:5000`) — Kamal sees that as
19
+ # a host and won't add the configured server in front of it.
17
20
  def call
18
21
  image = parsed["image"]
19
22
  registry = parsed["registry"] || parsed.dig("builder", "registry") || {}
@@ -23,11 +26,17 @@ module Kamal
23
26
  normalized_server = server.sub(%r{/+\z}, "")
24
27
  return [] if DOCKER_HUB_HOSTS.include?(normalized_server)
25
28
 
26
- prefix = "#{normalized_server}/"
27
- return [] if image.start_with?(prefix)
29
+ first_segment = image.split("/", 2).first.to_s
30
+ looks_like_host = first_segment.include?(".") || first_segment.include?(":")
31
+
32
+ # Unprefixed `org/repo` is the canonical Kamal style — the registry
33
+ # server is prepended automatically. Only flag when the image already
34
+ # carries a registry host that disagrees with `registry.server`.
35
+ return [] unless looks_like_host
36
+ return [] if image.start_with?("#{normalized_server}/")
28
37
 
29
38
  [ finding(
30
- message: "image `#{image}` does not include the configured registry `#{server}`; Kamal will push to the wrong registry",
39
+ message: "image `#{image}` is prefixed with a registry host that disagrees with `registry.server: #{server}`; Kamal will push to the wrong registry",
31
40
  line: context.line_for([ "image" ])
32
41
  ) ]
33
42
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Checks
6
+ # Opt-in: surfaces errors from Kamal's own loader as findings.
7
+ # Off by default because Kamal's parse-level validation is what
8
+ # `kamal config` is for — the linter's value-add is the cross-section
9
+ # checks. Enabled with `--include-kamal-errors`.
10
+ class KamalParseError < Check
11
+ id "kamal-parse-error"
12
+ severity :error
13
+ since "2.0.0"
14
+ title "Kamal's own loader rejected this config"
15
+
16
+ def call
17
+ return [] unless context.include_kamal_errors
18
+ return [] unless context.kamal_load_error
19
+
20
+ [ finding(
21
+ message: "kamal could not load this config: #{context.kamal_load_error.message}",
22
+ line: 1
23
+ ) ]
24
+ end
25
+ end
26
+
27
+ Lint.registry.register(KamalParseError)
28
+ end
29
+ end
30
+ end
@@ -7,23 +7,27 @@ module Kamal
7
7
  id "kamal-secrets-not-gitignored"
8
8
  severity :warning
9
9
  since "2.0.0"
10
- autofixable true
11
10
  title ".kamal/secrets is not in .gitignore"
12
11
 
13
12
  # We only flag when:
14
13
  # - a .kamal/secrets file exists (so there's something to leak), AND
15
- # - it is NOT covered by .gitignore (or .gitignore is missing).
14
+ # - it is NOT covered by .gitignore (or .gitignore is missing), AND
15
+ # - it contains at least one raw literal value (not just shell
16
+ # substitutions like `$(cmd)`, `${VAR}`, or `$VAR`). Files that
17
+ # only reference secrets via substitution are safe to commit.
16
18
  def call
17
19
  return [] unless File.exist?(context.secrets_path)
18
20
  return [] if gitignored?
21
+ return [] unless contains_literal_secret?
19
22
 
20
23
  [ 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
+ message: ".kamal/secrets exists but isn't ignored by .gitignore; add `.kamal/secrets` to .gitignore",
25
+ line: 1
24
26
  ) ]
25
27
  end
26
28
 
29
+ private
30
+
27
31
  def gitignored?
28
32
  return false unless File.exist?(context.gitignore_path)
29
33
 
@@ -39,14 +43,40 @@ module Kamal
39
43
  end
40
44
  end
41
45
 
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
46
+ # A value is "safe" when it sources its content from outside the file:
47
+ # FOO=$(cmd ...) # command substitution
48
+ # FOO=${VAR:-fallback} # parameter expansion
49
+ # FOO=$VAR # plain variable
50
+ # FOO= # empty
51
+ # Anything else (literal token, quoted string, fragment) is treated as
52
+ # a raw secret and triggers the finding.
53
+ SAFE_VALUE = /\A
54
+ \s* # leading whitespace
55
+ (?:
56
+ \z # empty
57
+ |
58
+ \$\(.*\) # $( ... )
59
+ |
60
+ \$\{.*\} # ${ ... }
61
+ |
62
+ \$[A-Za-z_][A-Za-z0-9_]* # $VAR
63
+ )
64
+ \s*\z
65
+ /x
66
+
67
+ def contains_literal_secret?
68
+ return false unless File.exist?(context.secrets_path)
69
+
70
+ File.foreach(context.secrets_path).any? do |raw|
71
+ line = raw.strip
72
+ next false if line.empty? || line.start_with?("#")
73
+
74
+ line = line.sub(/\Aexport\s+/, "")
75
+ _name, eq, value = line.partition("=")
76
+ next false if eq.empty?
77
+
78
+ !SAFE_VALUE.match?(value)
79
+ end
50
80
  end
51
81
  end
52
82
 
@@ -7,7 +7,6 @@ module Kamal
7
7
  id "missing-service-name"
8
8
  severity :error
9
9
  since "2.0.0"
10
- autofixable true
11
10
  title "`service:` is required and missing"
12
11
 
13
12
  def call
@@ -16,28 +15,9 @@ module Kamal
16
15
 
17
16
  [ finding(
18
17
  message: "`service:` is required; without it Kamal can't name the deployed container",
19
- line: 1,
20
- autofix: method(:apply_fix)
18
+ line: 1
21
19
  ) ]
22
20
  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
21
  end
42
22
 
43
23
  Lint.registry.register(MissingServiceName)
@@ -7,49 +7,16 @@ module Kamal
7
7
  id "traefik-legacy-keys"
8
8
  severity :warning
9
9
  since "2.0.0"
10
- autofixable true
11
10
  title "Kamal 1.x `traefik:` keys present (use `proxy:` in Kamal 2+)"
12
11
 
13
12
  def call
14
13
  return [] unless parsed.key?("traefik")
15
14
 
16
15
  [ 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)
16
+ message: "`traefik:` block is Kamal 1.x legacy and is ignored in Kamal 2+; replace it with a `proxy:` block",
17
+ line: context.line_for([ "traefik" ])
20
18
  ) ]
21
19
  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
20
  end
54
21
 
55
22
  Lint.registry.register(TraefikLegacyKeys)