k8s-rails 0.3.1 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2cc2a511299ef408878750a8e79b5059366dac9d6449f680b1b45a3760bd14f
4
- data.tar.gz: 5d63ba4fd3ea61db8f4ee0dd8fead1bb49f991a600fce91d5cf229d51bd32824
3
+ metadata.gz: 8241efc182ed9a328aa6a4b2ac652588f08282100e47f0255b1f3dfd76020dbb
4
+ data.tar.gz: fa4fb9ae8b57a2a4aac1b60a7868e58a840d6ad8d3a85a6e22ec26b34e08b045
5
5
  SHA512:
6
- metadata.gz: 77221c5e720c8f28b5233e154f98dd0852257f7e62af44772dff7b1d31b62de03582f75f94c67065535754f8ba403d5d363a3cb9af600ff8914ec67f727e56af
7
- data.tar.gz: 251139702bb75e006b17fbac9b53dab626794bbbc914edfb37df6586d508c1a070c8809bc027f10afe66200e525f9796d69a58b06c2c7b19174f514df0e9b57a
6
+ metadata.gz: 99dbf84cca496a64160719e1e3e7da245de3192eee25c5978b38bf5831794ca258a5c28f4f6aff301da86c9d33da61c4b5a301a539de81d4c20f4be96aa5de32
7
+ data.tar.gz: 40f07ca07971b618592938444ce7071b1e312ec5dbe9146754523ed017263f8337bc081f203b1ecd24ef21d52b049e7ddaa6b4cba32bbc38fa57ae3c96d1b6db
data/CHANGELOG.md CHANGED
@@ -1,92 +1,206 @@
1
1
  # Change Log
2
2
 
3
- `k8s-rails` の全 notable な変更はこのファイルに記録する。
3
+ All notable changes to `k8s-rails` are documented in this file.
4
+
5
+ ## 0.4.0
6
+
7
+ - **kubeconfig load failures now surface as `K8sRails::Unavailable`** (#30):
8
+ `Kubernetes::Configuration.default_config` runs before any request, so a
9
+ broken kubeconfig (e.g. a `current-context` that does not exist) leaked the
10
+ raw `Kubernetes::ConfigError` out of `K8sRails.client` / `K8sRails.connected?`.
11
+ The config-resolution stage (`Client.build_configuration`) now applies the
12
+ same conversion as the transport-time path — `Unavailable` with the original
13
+ `Kubernetes::ConfigError` preserved as `#cause` — for both `Resource.list`
14
+ and `connected?`. The rescue is narrow (`ConfigError` only), so programming
15
+ errors still propagate unconverted.
16
+ - **`connected?` now probes the same cached connection as Resource
17
+ operations** (#31): the resolved configuration is memoized alongside the
18
+ cached transport (`@resolved_connection_config`), so a config resolution that re-resolves
19
+ to a different connection after the first call no longer lets the probe
20
+ validate connection B while operations still send to connection A. There is
21
+ no implicit re-resolution; `K8sRails.reset!` remains the explicit reload
22
+ path (after it, both the probe and the next API call resolve the
23
+ configuration anew). A failed resolution (e.g. a broken kubeconfig) is not
24
+ memoized and is re-attempted on the next call. The first resolution, the
25
+ K1 bridge, and the transport build run inside one internal lock
26
+ (`Client::BUILD_LOCK`), so a concurrent first Resource operation and first
27
+ `connected?` call resolve the configuration exactly once in a threaded
28
+ Rails process (the probe's HTTP I/O stays outside the lock) — a
29
+ deterministic regression test asserts a single resolution under a racing
30
+ build/probe and fails on the pre-lock code.
31
+
32
+ - **`find` / `patch` / `delete` (and `*_cluster`) reject invalid target names
33
+ before any request is issued** (#28): `nil`, an empty string, `"."` or
34
+ `".."` raise `ArgumentError`. Previously `delete(nil)` built the
35
+ collection URL, which is a Delete Collection call — an unintended bulk
36
+ delete when the caller has the `deletecollection` permission; `"."` /
37
+ `".."` were normalized away by the HTTP client into a different resource
38
+ path. Gate order is unchanged: scope gate, then readonly gate, then name
39
+ validation (rejected calls are not instrumented).
40
+ - **The K1 bearer-token bridge keeps a rotated token fresh** (#29): the
41
+ bridge no longer freezes the first copy of
42
+ `api_key['authorization']` → `api_key['BearerToken']`; it re-syncs on
43
+ every request (kruby calls `auth_settings` per request, which is exactly
44
+ where kruby's in-cluster token-refresh hook runs). Long-running processes
45
+ using an in-cluster ServiceAccount token now pick up rotated tokens
46
+ without rebuilding the client. The token sent by a request is taken from
47
+ the value refreshed on that same call and is never re-read from the
48
+ shared configuration afterwards, so concurrent requests in a threaded
49
+ process cannot send a stale copy. A `BearerToken` set explicitly by the
50
+ application is never bridged or overwritten: before client creation the
51
+ bridge is not installed, and if the app replaces the value after the
52
+ client exists (explicit credential rotation), the bridge stops syncing
53
+ and the app's value stays authoritative.
54
+ - **`k8s-rails.request` notifications now use the standard ActiveSupport block
55
+ form** (#32): the API call runs inside
56
+ `ActiveSupport::Notifications.instrument`, so the standard start/finish
57
+ times bracket the real processing
58
+ (`ActiveSupport::Notifications::Event#duration` now reports the actual call
59
+ duration — previously the event was emitted after the call, so the standard
60
+ duration was ~0) and failures carry the standard
61
+ `exception` / `exception_object` metadata. The custom §8 payload
62
+ (`operation` / resource coordinates / `duration_ms` / closed `status` enum)
63
+ and the no-op / return-value contracts are unchanged; `status` and
64
+ `duration_ms` are set in the block's `ensure`, so the finish event payload
65
+ always carries them and the original exception is still re-raised.
66
+
67
+ - **A finite HTTP timeout (30s) is now applied by default** (#33,
68
+ behavior change): kruby's `Kubernetes::Configuration` defaults `timeout`
69
+ to `0`, which in libcurl means NO timeout — an unresponsive API server
70
+ could hold a Rails request or background job indefinitely. The gem now
71
+ applies a new `config.http_timeout` (default 30 seconds) at connection
72
+ resolution, ONLY when the resolved connection's `timeout` still carries
73
+ kruby's unset value (`0`): an explicitly set `config.connection.timeout`
74
+ is never overridden. Set `config.http_timeout = 0` to opt out and restore
75
+ the previous unlimited wait. The same policy covers Resource operations
76
+ and `K8sRails.connected?`. `http_timeout` accepts a non-negative Integer
77
+ or nil; other values raise `ArgumentError` (a `Float` like `0.5` would be
78
+ truncated by libcurl to `0` = unlimited). A finite timeout surfaces a hung
79
+ connection as `K8sRails::Unavailable` (kruby reports it as `ApiError`
80
+ code 0, "Connection timed out"). The gem still adds no retry layer of its
81
+ own; kruby's optional `retry_configuration` re-runs the full request per
82
+ retryable status, so the total wait scales with `max_retries` (timed-out
83
+ requests are never retried) — documented in the README.
84
+ - **kruby 1.36.5.2 compatibility verified** (#41): the dependency pin
85
+ (`~> 1.36.0`) already admits the new release, so no pin change and no
86
+ consumer change is needed. The client library source is byte-identical to
87
+ 1.36.2.1 except the version string (verified by a gem-dir diff), so the
88
+ §7 upgrade facts (call_api signature, K1 bridge, config discovery order)
89
+ hold unchanged. The full suite (132 examples / 0 failures, 22 files no
90
+ offenses) was re-run on Ruby 3.3.8, 3.5.0-preview1, and 4.0.7 with kruby
91
+ 1.36.5.2 installed. kruby 1.36.5.2 declares `logger` as a runtime
92
+ dependency (Ruby 4.0 promoted it to a bundled gem); it resolves
93
+ transitively for this gem.
94
+ - **Ruby 4.1.0-preview1 is excluded from the declared range** (#41, PR #42
95
+ review): it was published on 2026-10-17 (ruby/ruby tags) and, because
96
+ `Gem.ruby_version` reports the prerelease form `4.1.0.preview1`, the
97
+ previous `>= 3.3, < 4.1` interval admitted it at runtime. The gemspec now
98
+ adds `!= 4.1.0.preview1` — RubyGems requirements are conjunctive, so a
99
+ single-version exclusion is expressible without a union (verified:
100
+ `4.1.0.preview1` rejected; `4.0.7` and `3.5.0.preview1` admitted; `4.1`
101
+ rejected). The preview stays out of the CI matrix because `bundle install`
102
+ fails under it with the upstream RubyGems/Bundler bug
103
+ `uninitialized constant Pathname::SEPARATOR_PAT` (Bundler 4.0.23,
104
+ verified locally 2026-10-17); drop the exclusion and add the shard once
105
+ rubygems fixes it or 4.1 goes stable. `!=` excludes exactly one version,
106
+ so future previews (4.1.0.preview2+) need their own entries until then.
107
+ Ruby 4.0 support itself is unaffected and re-verified on 4.0.7 (see above).
4
108
 
5
109
  ## 0.3.1
6
110
 
7
- - **gemspec 文言の同期(RubyGems ページ表示の更新)**: `summary` / `description` を
8
- v0.3.0 の対応範囲に合わせ更新(PR #25)。公開済み gem の文言は公開後に変更できないため、
9
- 0.3.1 の公開で RubyGems ページの表示が新文言
10
- ("Kubernetes API access convention layer for Rails applications")に切り替わります。
11
- **コード・挙動の変更はありません。**
111
+ - **Gemspec wording sync (RubyGems page text update)**: `summary` / `description`
112
+ updated to match the v0.3.0 scope (PR #25). Published gem text cannot be
113
+ changed after push, so publishing 0.3.1 switches the RubyGems page to the new
114
+ wording ("Kubernetes API access convention layer for Rails applications").
115
+ **No code or behavior change.**
12
116
 
13
117
  ## 0.3.0
14
118
 
15
- - **対応リソースの拡大: core v1 built-in(Pod / Service / ConfigMap / Node 等)に対応**。
16
- 従来、transport は kruby の `CustomObjectsApi`(`/apis/{group}/...` 固定パス)
17
- に依存しており、`group: ""` の core v1 リソース(`/api/v1/...`)だけは 404
18
- で到達できなかった(named built-in は従来から動作)。transport を
19
- `Kubernetes::ApiClient#call_api` 上の**統一 REST 層**に改訂し、
20
- core v1 / named built-in / CRD の全てを宣言座標からのパス構築で到達させる。
21
- named 系・CRD の既存宣言の挙動は不変。
22
- - **`delete` / `delete_cluster` を追加**(CRUD 完成)。readonly ゲートは
23
- create / patch と同型(`readonly: true` 宣言で呼ぶと `ReadOnlyError`)。
24
- K8s は削除成功時に Status オブジェクト(`{"kind":"Status","status":"Success"}`)を返す。
25
- - **テスト注入スタブの契約変更(テストコードへの影響)**: `config.api_client`
26
- が実装すべきメソッドは CustomObjectsApi の 8 メソッドから
27
- `call_api(http_method, path, opts)` の 1 メソッドに統一
28
- (戻り値 `[data, status_code, headers]`、http_method は
29
- `:GET` / `:POST` / `:PATCH` / `:DELETE`)。既存スタブは `call_api` に
30
- 置き換える必要がある(実クラスタ接続の production コードには影響なし)。
31
- - `k8s-rails.request` notification の `operation` に `:delete` を追加。
32
- - 実クラスタ(microk8s v1.33.13)で Pod list / Deployment find /
33
- ConfigMap create・patch・delete / Node list_cluster を E2E 検証済み。
119
+ - **Expanded resource support: core v1 built-ins (Pod / Service / ConfigMap /
120
+ Node, etc.) are now supported.** Previously the transport relied on kruby's
121
+ `CustomObjectsApi` (fixed `/apis/{group}/...` paths), so core v1 resources
122
+ with `group: ""` (`/api/v1/...`) could not be reached (404), while named
123
+ built-ins worked all along. The transport was rewritten as a **unified REST
124
+ layer** on `Kubernetes::ApiClient#call_api`, reaching core v1 / named
125
+ built-in / CRD alike by building the path from the declaration coordinates.
126
+ Existing named built-in and CRD declarations behave exactly as before.
127
+ - **Added `delete` / `delete_cluster`** (completing CRUD). The readonly gate is
128
+ the same shape as create / patch (calling with a `readonly: true` declaration
129
+ raises `ReadOnlyError`). K8s returns a Status object
130
+ (`{"kind":"Status","status":"Success"}`) on successful delete.
131
+ - **Test injection stub contract change (affects test code)**: the method
132
+ `config.api_client` must implement is now the single
133
+ `call_api(http_method, path, opts)` (returning `[data, status_code, headers]`,
134
+ http_method being `:GET` / `:POST` / `:PATCH` / `:DELETE`), unified from the
135
+ 8 CustomObjectsApi methods. Existing stubs need to be replaced with
136
+ `call_api` (production code talking to a real cluster is unaffected).
137
+ - Added `:delete` to the `operation` of the `k8s-rails.request` notification.
138
+ - E2E-verified on a real cluster (microk8s v1.33.13): Pod list / Deployment
139
+ find / ConfigMap create/patch/delete / Node list_cluster.
34
140
 
35
141
  ## 0.2.1
36
142
 
37
- - **Ruby 3.5 / 4.0 対応の宣言**(宣言範囲拡大のみ・コード・挙動変更なし):
38
- 2026-09-22 に Ruby 4.0.7(stable・v4.0.7、2026-09-15 リリース)と
39
- Ruby 3.5.0-preview1(3.5 系で公開済みの唯一のリリース)で全
40
- rspec / rubocop を実行し緑を確認したため(kruby 1.36.4.1 は両方で
41
- install / 動作確認)、`required_ruby_version` を `>= 3.3, < 4.0` から
42
- **`>= 3.3, < 4.1`** に拡大。上限は検証済みの 4.0 系に設定し、
43
- CI matrix は 3.3.0 / 3.3.8 / 3.4.10 / 3.5.0-preview1 / 4.0.7 の 5 系統で
44
- 範囲内の公開済み Ruby を全カバー(宣言範囲 = 検証範囲)。
45
- 設計書 KBR-DESIGN-001 v0.1.13(案)へ更新(§6 / §10 / §13 / §14)。
143
+ - **Declared support for Ruby 3.5 / 4.0** (declaration range widened only — no
144
+ code or behavior change): on 2026-09-22, the full rspec / rubocop suite ran
145
+ green on Ruby 4.0.7 (stable, v4.0.7, released 2026-09-15) and
146
+ Ruby 3.5.0-preview1 (the only released 3.5 build) (kruby 1.36.4.1
147
+ installed and verified on both), so `required_ruby_version` was widened from
148
+ `>= 3.3, < 4.0` to **`>= 3.3, < 4.1`**. The upper bound is set to the
149
+ verified 4.0 series, and the CI matrix covers 3.3.0 / 3.3.8 / 3.4.10 /
150
+ 3.5.0-preview1 / 4.0.7 — every released Ruby in the range (declared range =
151
+ tested range). Design doc KBR-DESIGN-001 updated to v0.1.13 (draft)
152
+ (§6 / §10 / §13 / §14).
46
153
 
47
154
  ## 0.2.0
48
155
 
49
- - **cluster-scoped CRD サポート**(#17): `K8sRails.crd` に `scope: :namespaced`
50
- (既定)/ `:cluster` を追加。cluster-scoped CRD(ClusterIssuer 等)は
51
- `scope: :cluster`(`namespace:` 併用不可)で宣言し、`list_cluster` /
52
- `find_cluster` / `find_or_nil_cluster` / `create_cluster` /
53
- `patch_cluster` を使う。スコープと非対称な呼び出し(namespaced 宣言で
54
- `*_cluster` / cluster 宣言で素のメソッド)は `ArgumentError`。
55
- transport は kruby の `*_cluster_custom_object` 4 メソッドを新たに利用。
56
- - **`configure` のアトミック契約**(#16): 「一度だけ」はブロックが
57
- 正常終了した場合のみ成立。ブロックが異常終了した(任意の例外 —
58
- `LoadError` / `ScriptError` を含む — / `throw` / non-local return 等)
59
- 場合は設定済みフラグがリセットされ、後続の `configure` は通常どおり
60
- 実行される。異常終了前に書き込まれた属性は残存する(再実行ブロックは
61
- 依存する属性を全て設定する責務を負う。ロールバックはしない)。
62
- - **`connected?` の注入契約**(#18): `config.api_client` 注入時は
63
- I/O なしで `true`(注入トランスポートが接続面そのもの)。
64
- 非注入時は従来どおり VersionApi プローブ。
65
- - **接続設定探索順序の修正**(#19): README / 設計書 / 設定コメントの
66
- 自動検出順序を kruby 1.36.x の loader 実装順
67
- (**KUBECONFIG → `~/.kube/config` → in-cluster**、in-cluster は最後)
68
- に修正(従来の「in-cluster → KUBECONFIG」記述は誤り)。kruby 上げ替え
69
- 時の再確認手順を設計書 §7 に追加。
70
- - テスト注入スタブ(`config.api_client`)の契約: namespaced 4 メソッド +
71
- cluster 4 メソッド(namespaced 宣言のみ使う場合は前者 4 メソッドで足りる)。
72
- - 設計書 KBR-DESIGN-001 v0.1.12(案)へ更新(§5.1 / §5.2 / §5.3 / §6 /
73
- §7 / §9 / §14)。
156
+ - **Cluster-scoped CRD support** (#17): `K8sRails.crd` gains
157
+ `scope: :namespaced` (default) / `:cluster`. Cluster-scoped CRDs
158
+ (ClusterIssuer, etc.) are declared with `scope: :cluster` (must not be
159
+ combined with `namespace:`) and used via `list_cluster` / `find_cluster` /
160
+ `find_or_nil_cluster` / `create_cluster` / `patch_cluster`. Scope-asymmetric
161
+ calls (`*_cluster` on a namespaced declaration / plain methods on a cluster
162
+ declaration) raise `ArgumentError`. The transport now also uses kruby's
163
+ 4 `*_cluster_custom_object` methods.
164
+ - **Atomic contract for `configure`** (#16): "only once" holds only when the
165
+ block finishes normally. If the block terminates abnormally (any exception —
166
+ including `LoadError` / `ScriptError` — / `throw` / non-local return, etc.),
167
+ the configured flag is reset and a subsequent `configure` runs as usual.
168
+ Attributes written before the abnormal exit remain (the re-run block is
169
+ responsible for setting all attributes it depends on; no rollback is
170
+ performed).
171
+ - **Injection contract for `connected?`** (#18): when `config.api_client` is
172
+ injected, it returns `true` without I/O (the injected transport IS the
173
+ connection surface). Without injection, the VersionApi probe runs as before.
174
+ - **Connection lookup order fix** (#19): the documented auto-detection order in
175
+ README / design doc / config comments was corrected to kruby 1.36.x's loader
176
+ implementation order (**KUBECONFIG → `~/.kube/config` → in-cluster**,
177
+ in-cluster LAST; the previous "in-cluster → KUBECONFIG" wording was wrong).
178
+ A re-verification procedure for kruby upgrades was added to design doc §7.
179
+ - Test injection stub (`config.api_client`) contract: 4 namespaced methods +
180
+ 4 cluster methods (the 4 namespaced methods suffice if only namespaced
181
+ declarations are used).
182
+ - Design doc KBR-DESIGN-001 updated to v0.1.12 (draft) (§5.1 / §5.2 / §5.3 /
183
+ §6 / §7 / §9 / §14).
74
184
 
75
185
  ## 0.1.0
76
186
 
77
- - **M0**: gem 骨子(gemspec / Gemfile / Rakefile / version / require 構造)
78
- - **M1**: Configuration + Client(lazy connect・K1 BearerToken 橋渡し)・例外体系
79
- (`Unavailable` / `NotFound` / `ApiError` + `ReadOnlyError` / `RedeclarationError`)
80
- - **M2**: CRD 宣言 DSL + Resource(`list` / `find` / `find_or_nil` / `create` / `patch`、
81
- `readonly` は明示 boolean、再宣言は `RedeclarationError`)
82
- - **M3**: 計測(ActiveSupport::Notifications 経由、無効時 no-op)+ README + rubocop
83
- - **M4**: consumer Rails アプリの K8s サービス移行で動作検証
84
- (実クラスタ microk8s v1.33.13)
85
- - 応答は常に文字列キー(K2)・純 Ruby `Normalizer`(ActiveSupport 非依存)
86
- - 対応 Ruby: `>= 3.3, < 4.0`(下限は kruby 1.36.x の `>= 3.3`。上限は
87
- 未検証の Ruby 4.x を宣言から除外するため。4.0 / 3.5 対応は v0.2 以降
88
- で検証の上宣言に含める)。
89
- CI(GitHub Actions)で 3.3.0 / 3.3.8 / 3.4.10 の matrix 検証
90
- - 対応 Kubernetes サーバ: v1.33.x で実クラスタ検証済み(README 参照)
91
- - 公開導線: owner のローカル PC から手動 `gem push`(rake 全緑確認 +
92
- CHANGELOG 確定後に実施。CI の自動公開は行わない)
187
+ - **M0**: gem skeleton (gemspec / Gemfile / Rakefile / version / require layout)
188
+ - **M1**: Configuration + Client (lazy connect, K1 BearerToken bridging),
189
+ exception hierarchy (`Unavailable` / `NotFound` / `ApiError` +
190
+ `ReadOnlyError` / `RedeclarationError`)
191
+ - **M2**: CRD declaration DSL + Resource (`list` / `find` / `find_or_nil` /
192
+ `create` / `patch`; `readonly` is an explicit boolean; re-declaration raises
193
+ `RedeclarationError`)
194
+ - **M3**: instrumentation (via ActiveSupport::Notifications, no-op when
195
+ disabled) + README + rubocop
196
+ - **M4**: verified in a consumer Rails app's K8s service migration (real
197
+ cluster, microk8s v1.33.13)
198
+ - Responses are always string-keyed (K2); pure-Ruby `Normalizer` (no
199
+ ActiveSupport dependency)
200
+ - Supported Ruby: `>= 3.3, < 4.0` (floor from kruby 1.36.x's `>= 3.3`; the
201
+ upper bound excludes unverified Ruby 4.x. 4.0 / 3.5 support to be verified
202
+ and declared in v0.2+). CI (GitHub Actions) matrix over 3.3.0 / 3.3.8 / 3.4.10
203
+ - Supported Kubernetes server: verified on v1.33.x against a real cluster (see
204
+ README)
205
+ - Publishing path: manual `gem push` from the owner's local PC (after rake
206
+ fully green + CHANGELOG finalized; no CI auto-publish)
data/README.md CHANGED
@@ -16,7 +16,7 @@ For the design rationale, see the [design document (docs/design.md)](docs/design
16
16
 
17
17
  | Item | Supported range | Notes |
18
18
  |------|-----------------|-------|
19
- | Ruby | `>= 3.3, < 4.1` | Floor: kruby 1.36.x requires Ruby 3.3. Ruby 4.0 support verified on Ruby 4.0.7 (2026-09-22); Ruby 3.5 verified on 3.5.0-preview1 (the only released 3.5, 2026-09-22). CI verifies the declared range with a 3.3.0 / 3.3.8 / 3.4.10 / 3.5.0-preview1 / 4.0.7 matrix — every released Ruby in the range |
19
+ | Ruby | `>= 3.3, < 4.1` (excluding `4.1.0.preview1`) | Floor: kruby 1.36.x requires Ruby 3.3. Ruby 4.0 support verified on Ruby 4.0.7 (2026-09-22, re-verified with kruby 1.36.5.2 on 2026-10-18); Ruby 3.5 verified on 3.5.0-preview1 (the only released 3.5, re-verified 2026-10-18). CI verifies the declared range with a 3.3.0 / 3.3.8 / 3.4.10 / 3.5.0-preview1 / 4.0.7 matrix. Ruby 4.1.0-preview1 (published 2026-10-17) is excluded from the range with `!= 4.1.0.preview1`: `Gem.ruby_version` reports the prerelease form `4.1.0.preview1`, which `< 4.1` alone admits. The preview has no runnable CI shard yet (`bundle install` fails under it with the upstream RubyGems/Bundler bug `uninitialized constant Pathname::SEPARATOR_PAT`, Bundler 4.0.23; verified 2026-10-17) — the exclusion lifts and the shard lands once rubygems fixes it or 4.1 goes stable. `!=` excludes exactly one version, so later previews (4.1.0.preview2+) need their own entries until then |
20
20
  | kruby | `~> 1.36.0` | The official Kubernetes OpenAPI client |
21
21
  | Kubernetes server | **Verified on v1.33.x** (real cluster, microk8s v1.33.13, 2026-09-21) | The transport speaks the generic Kubernetes REST API, so newer servers (1.36, etc.) are expected to work but are not yet verified on a real cluster |
22
22
  | Dependencies | **kruby only** at runtime | ActiveSupport is used only for optional [instrumentation](#instrumentation-optional-activesupport) (no-op when absent) |
@@ -25,12 +25,12 @@ For the design rationale, see the [design document (docs/design.md)](docs/design
25
25
 
26
26
  ```ruby
27
27
  # Gemfile
28
- gem "k8s-rails", "~> 0.3"
28
+ gem "k8s-rails", "~> 0.4"
29
29
  ```
30
30
 
31
31
  ```ruby
32
32
  require "k8s-rails"
33
- K8sRails::VERSION # => "0.3.1"
33
+ K8sRails::VERSION # => "0.4.0"
34
34
  ```
35
35
 
36
36
  `require` is side-effect-free and needs no cluster. kruby itself is loaded
@@ -137,6 +137,7 @@ Workflow.delete("wf-1") # readonly: f
137
137
  ```ruby
138
138
  K8sRails.configure do |config|
139
139
  config.namespace = "team-a" # default namespace (default: "default")
140
+ # config.http_timeout = 30 # HTTP timeout in seconds (default: 30; 0 = unlimited)
140
141
  # config.connection = my_config # pass a Kubernetes::Configuration directly (optional)
141
142
  # config.instrumentation = false # disable instrumentation (default true; only effective when ActiveSupport is present)
142
143
  # config.api_client = stub # test-only: inject a transport (see [Testing](#testing))
@@ -159,6 +160,63 @@ end
159
160
  in-cluster. Note in-cluster is tried **last**, after the file-based
160
161
  sources — re-verify `kruby`'s loader when upgrading kruby).
161
162
 
163
+ ### HTTP timeouts (default 30s)
164
+
165
+ kruby's `Kubernetes::Configuration` defaults `timeout` to `0`, which in
166
+ libcurl means **no timeout** — an unresponsive API server can hold a Rails
167
+ request or background job indefinitely. To make that safe by default, the gem
168
+ applies a finite timeout at connection resolution:
169
+
170
+ ```ruby
171
+ K8sRails.configure do |config|
172
+ config.http_timeout = 30 # seconds (default: 30)
173
+ end
174
+ ```
175
+
176
+ - The value applies **only** when the resolved connection's `timeout` still
177
+ carries kruby's unset value (`0`). If you set one explicitly, the gem never
178
+ overrides it:
179
+
180
+ ```ruby
181
+ require "kubernetes"
182
+
183
+ my_config = Kubernetes::Configuration.default_config
184
+ my_config.timeout = 60 # explicit value — the gem never overrides it
185
+ K8sRails.configure do |config|
186
+ config.connection = my_config
187
+ end
188
+ ```
189
+ - `config.http_timeout = 0` opts out entirely and restores kruby's unlimited
190
+ wait. (kruby's own `timeout = 0` is indistinguishable from "never set", so
191
+ the opt-out lives on this knob rather than on the kruby configuration.)
192
+ - The same policy covers Resource operations **and** `K8sRails.connected?` —
193
+ both resolve the connection through the same path.
194
+ - A finite timeout turns a hung connection into `K8sRails::Unavailable`
195
+ (kruby surfaces it as `ApiError` code 0 with a "Connection timed out"
196
+ message; the gem converts it — see [Exception hierarchy](#exception-hierarchy)).
197
+ - Set `http_timeout` before the first `K8sRails.client` / API call: it is
198
+ applied once, when the connection is resolved. Changing
199
+ `config.connection.timeout` directly works at any time (kruby re-reads it
200
+ for every request).
201
+ - `http_timeout` accepts a non-negative **Integer** or `nil` (built-in
202
+ default). Other values raise `ArgumentError` — a `Float` like `0.5` would
203
+ be truncated by libcurl to `0` (= unlimited), silently recreating the
204
+ unbounded wait this default exists to prevent.
205
+
206
+ ### Retries
207
+
208
+ The gem does not add its own retry/backoff layer. kruby 1.36.x offers retries
209
+ on the resolved connection's `retry_configuration` hash
210
+ (`{ max_retries: N, base_interval_seconds: F, retry_statuses: [...] }`;
211
+ disabled by default — `max_retries` 0, and even when enabled only the status
212
+ codes 429/500/501/502/503 are retried, with the server's `Retry-After` taken
213
+ into account when present). When enabled, **the total wait scales with
214
+ `max_retries`**: every retryable response re-runs the full request plus the
215
+ exponential backoff between attempts, so one call can take roughly
216
+ `(1 + max_retries) × timeout` plus the intervals. Timed-out requests are
217
+ **not** retried. For broader failure handling, rely on Rails middleware /
218
+ background-job retries.
219
+
162
220
  ## Resource declaration and access
163
221
 
164
222
  The same `K8sRails.crd` API covers core v1 built-ins, named-group built-ins,
@@ -182,6 +240,13 @@ declaration (the `*_cluster` methods take no `namespace:`).
182
240
  `ArgumentError`). Writes (`create` / `patch` / `delete`) are enabled **only**
183
241
  by `readonly: false`, so a missing flag can never fail open.
184
242
 
243
+ Object-name arguments of `find` / `find_or_nil` / `patch` / `delete` (and
244
+ their `*_cluster` counterparts) are validated **before any request is
245
+ issued**: `nil`, an empty string, `"."` or `".."` raise `ArgumentError`.
246
+ This keeps `delete(nil)` from degrading into a Delete Collection call
247
+ against the whole collection, and keeps `"."` / `".."` from being normalized
248
+ away by the HTTP client into a different resource path.
249
+
185
250
  ### Cluster-scoped resources
186
251
 
187
252
  Both namespaced and cluster-scoped resources are supported. Declare a
@@ -231,6 +296,17 @@ It never returns `false` — a connection failure surfaces as an exception
231
296
  (handle it with `rescue`). The actual API connection is established lazily on
232
297
  the first API call.
233
298
 
299
+ The probe inspects the **same cached connection** the Resource operations
300
+ use — the resolved configuration is memoized for the lifetime of the cached
301
+ transport, so a config resolution that changes after the first call is not
302
+ re-resolved for the probe (it would otherwise validate connection B while
303
+ operations still use connection A). The first resolution, the K1 bridge, and
304
+ the transport build run inside one internal lock, so a concurrent Resource
305
+ operation and `connected?` call resolve the configuration exactly once (the
306
+ probe's HTTP I/O stays outside the lock). `K8sRails.reset!` is the explicit reload
307
+ path: after it, both the probe and the next API call resolve the
308
+ configuration anew.
309
+
234
310
  When a test transport is injected via `config.api_client`, `connected?`
235
311
  returns `true` without any network I/O — the injected transport **is** the
236
312
  connection surface, so probing a real endpoint would contradict the Resource
@@ -240,7 +316,7 @@ operations that the same injection serves.
240
316
 
241
317
  ```
242
318
  K8sRails::Error < StandardError
243
- ├── K8sRails::Unavailable # transport-layer failure (DNS/timeout/connection refused; kruby 1.36.x reports it as ApiError code 0)
319
+ ├── K8sRails::Unavailable # connection-level failure: transport (DNS/timeout/connection refused — kruby 1.36.x reports these as ApiError code 0) and kubeconfig load failures (original Kubernetes::ConfigError kept as #cause)
244
320
  ├── K8sRails::NotFound # HTTP 404
245
321
  ├── K8sRails::ApiError # other API errors (401/403/409/422/5xx); holds #code and #response
246
322
  ├── K8sRails::ReadOnlyError # create/patch/delete called on a readonly: true declaration
@@ -271,6 +347,12 @@ k8s-rails.request
271
347
  status: "ok" | "unavailable" | "api_error" }
272
348
  ```
273
349
 
350
+ The API call runs inside the standard notification block, so the event's
351
+ start/finish times bracket the real processing (`Event#duration` reports the
352
+ actual call duration) and a failure attaches the standard
353
+ `exception` / `exception_object` metadata next to the custom `status`. The
354
+ finish event payload always carries `status` and `duration_ms`.
355
+
274
356
  In a Rails app you can subscribe with `ActiveSupport::Notifications`:
275
357
 
276
358
  ```ruby
@@ -326,16 +408,29 @@ not match, **the Authorization header ends up empty and requests fail with
326
408
  401** when using kruby's configuration directly.
327
409
 
328
410
  k8s-rails automatically copies `authorization` to `BearerToken` when building
329
- its client (the design document calls this the "K1 bridge"), so connections
330
- through k8s-rails are unaffected (it does not overwrite an already-set
331
- `BearerToken`). If you see 401s from a cluster connection that relies on
332
- kruby's own configuration behavior (outside this gem), check this token-key
333
- issue first.
334
-
335
- ## Roadmap (v0.4+)
411
+ its client (the design document calls this the "K1 bridge"). The copy is not
412
+ frozen: the bridge re-syncs `BearerToken` from `authorization` on every
413
+ request (kruby calls `auth_settings` per request, which is exactly where
414
+ kruby's in-cluster token-refresh hook runs), so a rotated ServiceAccount
415
+ token reaches long-running processes without rebuilding the client. The
416
+ token sent by a request is taken from the value refreshed on that same call
417
+ and is never re-read from the shared configuration afterwards, so concurrent
418
+ requests in a threaded process cannot send a stale copy. A `BearerToken` set
419
+ by your application is never bridged or overwritten — before client creation
420
+ the bridge is simply not installed, and if your app replaces the value after
421
+ the client exists (explicit credential rotation), the bridge detects the
422
+ override, stops syncing, and your value stays authoritative. If you see 401s
423
+ from a cluster connection that relies on kruby's own configuration behavior
424
+ (outside this gem), check this token-key issue first.
425
+
426
+ ## Roadmap (v0.5+)
336
427
 
337
428
  - Update the CI matrix when Ruby 3.5 reaches a stable release (3.5 is
338
429
  verified today on 3.5.0-preview1, the only released 3.5)
430
+ - When Ruby 4.1 goes stable, drop the `!= 4.1.0.preview1` gemspec
431
+ exclusion, extend the declared range to `< 4.2`, and add 4.1 to the
432
+ matrix after verification (the preview is excluded until the upstream
433
+ Bundler `Pathname::SEPARATOR_PAT` bug is fixed)
339
434
  - CI-based E2E tests using kind
340
435
  - watch (streaming) support, under consideration
341
436
 
data/docs/design.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # k8s-rails 設計書
2
2
 
3
3
  - 文書番号: KBR-DESIGN-001
4
- - 版: 0.1.16(案)
5
- - 日付: 2026-09-22
4
+ - 版: 0.1.23(案)
5
+ - 日付: 2026-10-19
6
6
  - 対象リポジトリ: k8s-rails(本設計の実装先)
7
7
  - ライセンス: MIT(LICENSE は main に既存)
8
8
 
@@ -41,7 +41,7 @@ K8s CRD を扱えるようにし、(b) 実際の consumer アプリをこの gem
41
41
  ### 2.2 非スコープ(本設計の外)
42
42
 
43
43
  - **core v1 リソースの CustomObjects 経路でのアクセス** — 2026-09-22 に**解消済み**(unified REST transport 導入、§5.2 改訂)。以前は core v1(Pod / Service / ConfigMap / Node 等)が `/api/v1/...`(group 区間なし)のため `CustomObjectsApi` では扱えず、`group: ""` 宣言は 404 になった。**named-group の built-in(apps/v1 の Deployment 等)は CustomObjects 経路でも動作していた**(generic な `/apis/{group}/...` クライアントのため)。現在の transport は `Kubernetes::ApiClient#call_api` 上の統一 REST 層で、core v1(`group: ""` → `/api/v1/...`)・named built-in・CRD を 1 つの経路で扱う
44
- - **watch(ストリーム)** — v0.1 では非対応。kruby の watch は get/list より成熟度が低く、初版の API 保証範囲から外す(§10 で v0.4 対象)
44
+ - **watch(ストリーム)** — v0.1 では非対応。kruby の watch は get/list より成熟度が低く、初版の API 保証範囲から外す(§10 で v0.5 対象)
45
45
  - アプリの**デプロイ**(helm / kustomize 生成等) — `kuby-core` の領域
46
46
  - RBAC 権限の付与・管理 — 呼び出しアプリ側の ClusterRole/Role の責務
47
47
  - 複数クラスタ同時接続 — v0.1 は単一クラスタ前提(§11 展望)
@@ -106,7 +106,7 @@ k8s-rails/
106
106
  ├── lib/
107
107
  │ ├── k8s-rails.rb # エントリ。require 集 + モジュール定義
108
108
  │ └── k8s_rails/
109
- │ ├── version.rb # VERSION = "0.3.1"
109
+ │ ├── version.rb # VERSION = "0.4.0"
110
110
  │ ├── configuration.rb # Config: namespace, connection, 計測 ON/OFF
111
111
  │ ├── client.rb # 接続解決・BearerToken 橋渡し・計測ラップ
112
112
  │ ├── crd.rb # K8sRails.crd 宣言 → Resource 生成
@@ -130,6 +130,9 @@ k8s-rails/
130
130
  # config/initializers/k8s-rails.rb
131
131
  K8sRails.configure do |config|
132
132
  config.namespace = ENV.fetch("K8S_NAMESPACE", "default")
133
+ # HTTP タイムアウト(秒・#33)。既定 30。解決済み接続の `timeout` が kruby の
134
+ # 未設定値 `0` のときのみ適用。`0` で無制限にオプトアウト
135
+ # config.http_timeout = 30
133
136
  # 任意上書き(省略時は default_config の自動検出。kruby 1.36.x の探索順序は
134
137
  # KUBECONFIG → ~/.kube/config → in-cluster で、in-cluster が最後)
135
138
  # config.connection = Kubernetes::Configuration.default_config
@@ -142,6 +145,7 @@ end
142
145
  | キー | 既定 | 説明 |
143
146
  |---|---|---|
144
147
  | `namespace` | `"default"` | CRD 宣言が namespace 未指定時のデフォルト |
148
+ | `http_timeout` | `30` | HTTP タイムアウト(秒・#33)。接続解決時に、解決済み `Kubernetes::Configuration` の `timeout` が kruby の未設定値 `0` のときのみ適用(kruby 既定の `0` は libcurl 上**無制限待ち**で、Rails のリクエスト / ジョブを占有し得る)。`config.connection.timeout` に非 0 を明示した場合は**上書きしない**。`0` で kruby 既定の無制限にオプトアウト(kruby 側の `0` は「未設定」と区別できないため、オプトアウトは本キーで行う)。`nil` で組み込み既定値に戻す。非負 Integer / `nil` 以外は `ArgumentError`(`0.5` 等の Float は libcurl で `0`=無制限に丸められるため fail-fast)。`Resource` 操作と `connected?` の**双方**に同一適用(§5.2 の共通経路) |
145
149
  | `connection` | `nil`(自動検出) | `Kubernetes::Configuration` インスタンス。認証を上書きする場合に指定。省略時の探索順序(kruby 1.36.x の loader 実装順): **KUBECONFIG → `~/.kube/config` → in-cluster**(in-cluster はファイル系が両方無効な場合の**最後**。kruby 上げ替え時に loader を再確認すること — §7) |
146
150
  | `api_client` | `nil` | **テスト専用**: kruby の `Kubernetes::ApiClient#call_api` と同型の 1 メソッド `call_api(http_method, path, opts)`(戻り値 `[data, status_code, headers]`。`http_method` は `:GET` / `:POST` / `:PATCH` / `:DELETE`、`path` は宣言の座標から構築された REST パス)を実装する素のオブジェクト。指定時は `Client.build` が接続解決をスキープして `StringKeyedAdapter` で包んで使う(§5.2・§9) |
147
151
  | `instrumentation` | `true` | `ActiveSupport::Notifications` で計測する(§8) |
@@ -153,7 +157,7 @@ end
153
157
  は通常どおり実行される。ただし異常終了前に書き込まれた属性は共有 Configuration
154
158
  に**残存する**(部分的な設定状態になり得るため、再実行ブロックは依存する属性を
155
159
  全て設定する責務を負う。アトミックなロールバックは行わない — Configuration
156
- は純データで 4 属性のみのため、複写+スワップの複雑さに見合わない)。
160
+ は純データで 5 属性のみのため、複写+スワップの複雑さに見合わない)。
157
161
  - `K8sRails.reset!`(テスト用)で接続キャッシュ・宣言済 CRD を破棄できる。
158
162
 
159
163
  ### 5.2 接続
@@ -171,8 +175,8 @@ K8sRails.connected? # → 成功時は true。失敗は K8sRails::Unavailab
171
175
  旧実装は `CustomObjectsApi` 経由で CRD / named-group のみ、core v1 は 404):
172
176
 
173
177
  0. `config.api_client` があれば(テスト注入、§5.1)それを直接返し、以降の接続解決をスキップ
174
- 1. `config.connection` があればそれ、なければ `Kubernetes::Configuration.default_config`
175
- 2. **K1 橋渡し**: `api_key['authorization']` が `api_key['BearerToken']` に書かれていなければ複製
178
+ 1. `config.connection` があればそれ、なければ `Kubernetes::Configuration.default_config`。**解決済み config は cached transport の寿命とともに memoize**(#31)— `connected?` はこの memoized config をプローブし、Resource 操作と同じ接続を検査する。暗黙の再解決はない。明示的な再読み込みは `reset!`(§5.1)のみ。解決に失敗した場合(kubeconfig 不正等)は memoize せず次回呼び出しで再試行。**解決と transport 生成は同一クリティカルセクション(`Client::BUILD_LOCK`)で行う**(#31 レビュー指摘: `||=` は非アトミック — スレッド環境で並行する初回操作と初回プローブが双方 `build_configuration` を実行し、後書き勝ちで再び接続不一致を起こし得る。ロック内で config を 1 回だけ解決し、transport 生成・K1 ブリッジ・probe 用 config 取得のすべてが同じ解決結果を共有する。`reset!` も同一ロックを取り reset と build の交錯を防ぐ。プローブの HTTP I/O はロック外)。**HTTP タイムアウト適用(#33)**: 解決済み接続の `timeout` が kruby の未設定値 `0` のとき、`config.http_timeout`(既定 30 秒)を適用する(明示設定は上書きしない。`http_timeout = 0` で無制限にオプトアウト — kruby 側の `0` は「未設定」と区別できないためオプトアウトは gem 側キーで行う。適用は冪等)。kruby は `config.timeout` を**リクエストごとに**読むため、解決後に利用者が直接 `config.connection.timeout` を変更しても以降のリクエストに反映される
179
+ 2. **K1 橋渡し**: `api_key['authorization']` が `api_key['BearerToken']` に書かれていなければ複製し、その後の**全 `auth_settings` 呼び出し(= 全リクエスト)で再同期する**(kruby はリクエストごとに `auth_settings` を呼ぶ。in-cluster のトークン更新フックは `api_key_with_prefix("authorization")` 呼び出し時のみ発火するため、初回コピーに固定するとローテーション後のトークンがヘッダに到達せず長時間稼働プロセスで 401 が続く — #29 対応)。**スレッドセーフ**: リクエストが送出するトークンは当該呼び出しで新規取得した `authorization` 値を、その呼び出しの `auth_settings` 戻り値ハッシュに直接書き込むもので、共有 `api_key` への書き戻し後に再読みなし(並行リクエストの書き込みが挟んでも stale トークンを送出しない。共有キーは直接リーダ用の backing copy として更新を継続)。**app 側上書き**: ブリッジが最後に書いた値(`@k8s_rails_bridged_bearer_value` を追跡)と本次書き込み予定値のどちらも現 `BearerToken` と一致しない場合は、アプリが明示的に置き換えた(credential rotation)と判定し**同期を恒久的に停止** — app 側の値が権威となる(client 生成前の明示設定はブリッジ非設置、生成後の明示設定は同期停止で、いずれも上書きしない)
176
180
  3. `Kubernetes::ApiClient` を生成し、`StringKeyedAdapter` で包む。**パス構築**: 宣言の座標から REST パスを構築して `call_api` で実行 — core v1(`group == ""`)は `/api/{version}[/namespaces/{ns}]/{plural}[/{name}]`、named(CRD・named built-in)は `/apis/{group}/{version}[/namespaces/{ns}]/{plural}[/{name}]`。`return_type: "Object"` で kruby が JSON をパース(symbol キー)したオブジェクトを返し、**文字列キー化**(K2)を API レスポンス後に行う(`K8sRails::Normalizer`、ActiveSupport 非依存)
177
181
  4. **接続レベルの失敗**(DNS 失敗 / タイムアウト / 接続拒否等)は `K8sRails::Unavailable` に変換して `raise`(リトライはしない)。**kruby 1.36.x ではこれらの転送失敗は HTTP ステータスが無いため `Kubernetes::ApiError`(`code == 0`)として surfacing する**(§5.4 の変換表参照)。認可失敗(401/403)は §5.4 により `K8sRails::ApiError`
178
182
 
@@ -184,7 +188,7 @@ K8sRails.connected? # → 成功時は true。失敗は K8sRails::Unavailab
184
188
  `connected?` の解決順序:
185
189
 
186
190
  0. `config.api_client` があれば(テスト注入、§5.1)**I/O なしで `true` を返す**。注入されたトランスポートが接続面そのもののため、実エンドポイントへのプローブは同一注入下の Resource 操作と矛盾する(#18 対応)。クラスタ到達性の真の確認が必要な場合は注入を解除した環境で行う
187
- 1. 以下 `Client.build` と同一(`config.connection` → `default_config` → K1 橋渡し → VersionApi プローブ)
191
+ 1. 以下 `Client.build` と同一だが、**解決済み config は memoize されたものを使用**(#31)— 既に client が構築済みなら再解決せず、Resource 操作と同一の接続・認証設定を `/version` プローブで検査する。未構築なら 1 回解決して memoize(以後の操作と共有)。`reset!` が明示的な再読み込み経路(#31)。解決失敗時(kubeconfig 不正等)は memoize せず次回再試行(§5.4 の ConfigError 変換は維持)。**解決と K1 橋渡しは `BUILD_LOCK` 内、プローブの HTTP I/O はロック外**で実行(#31 レビュー指摘: `||=` 非アトミック対策 — 並行する初回操作と初回プローブで解決が厳密に 1 回しか走らないことを決定論的回帰テストで担保)
188
192
 
189
193
  ### 5.3 リソース宣言(CRD / built-in 共通)
190
194
 
@@ -248,6 +252,11 @@ ClusterIssuer = K8sRails.crd(
248
252
  - 宣言時に `scope: :cluster` と `namespace:` を併用した場合は `ArgumentError`(設定ミスの fail fast)。
249
253
  - transport は kruby の `ApiClient#call_api` 上の統一 REST 層で、namespaced / cluster の区別はパス構築(`/namespaces/{ns}` の有無)のみ。テスト注入スタブは `call_api` の 1 メソッドを実装する(§9)。
250
254
 
255
+ 対象名検証(#28 対応):
256
+
257
+ - `find` / `patch` / `delete`(cluster 版を含む)は**通信前に**無効な対象名を拒否する: `nil` / 空文字列 / `.` / `..` → `ArgumentError`。`nil` 名はパス構築上コレクション URL になり、その DELETE は Delete Collection 呼び出し(一括削除リスク)になるため、fail fast する。`.` / `..` は HTTP クライアントのパス正規化で送信先が変わる(実測で確認)。
258
+ - ゲート順: スコープゲート → readonly ゲート → 対象名検証。全ゲートは計測(§8)より前に発火するため、拒否された操作は計測対象にならない。
259
+
251
260
  - **戻り値は常に文字列キーの Hash**(K2 の規約を API 契約として固定)。
252
261
  `find` は存在しない場合 `K8sRails::NotFound` を raise(consumer アプリ側が `return nil` にしていたのは
253
262
  呼び出し側の都合。gem としては例外が明示的)。「存在しない場合は nil」が欲しい場合は
@@ -260,7 +269,7 @@ ClusterIssuer = K8sRails.crd(
260
269
 
261
270
  ```ruby
262
271
  K8sRails::Error < StandardError
263
- ├── K8sRails::Unavailable # 接続不能・タイムアウト・DNS 失敗等(クラスタ起因)
272
+ ├── K8sRails::Unavailable # 接続不能・タイムアウト・DNS 失敗・kubeconfig 読み込み失敗等(クラスタ / 接続設定起因)
264
273
  ├── K8sRails::NotFound # リソース不在(HTTP 404)
265
274
  ├── K8sRails::ApiError # その他の API エラー(401/403/409/422 等)。#code と #response を保持
266
275
  ├── K8sRails::ReadOnlyError # readonly: true 宣言で create/patch/delete が呼ばれた(設定ミス)
@@ -272,6 +281,7 @@ K8sRails::Error < StandardError
272
281
  | kruby 側 | → gem 側 |
273
282
  |---|---|
274
283
  | kruby 転送層例外(DNS 失敗 / タイムアウト / 接続拒否等。kruby 1.36.x では **`ApiError`(`code == 0`)** として surfacing) | `Unavailable` |
284
+ | `Kubernetes::ConfigError`(kubeconfig 読み込み / context 解決の失敗。**設定読み込み時** = `Client.build` / `connected?` の設定解決段階、**通信時** = adapter / probe の双方で発生し得る) | `Unavailable`(元例外を `cause` に保持 — 設定解決段階では raw `ConfigError` を公開しない) |
275
285
  | その他の `StandardError`(プログラミング/設定ミス、例: `NoMethodError`) | そのまま伝播(変換せず隠さない) |
276
286
  | `Kubernetes::ApiError` code 404 | `NotFound` |
277
287
  | `Kubernetes::ApiError` その他 | `ApiError`(code / response body を保持) |
@@ -293,7 +303,7 @@ end
293
303
 
294
304
  | 依存 | 制約 | 理由 |
295
305
  |---|---|---|
296
- | Ruby | `>= 3.3, < 4.1` | 下限: kruby 1.36.x が `required_ruby_version ">= 3.3"` を宣言(RubyGems API で実測 2026-09-21、1.36.0.1〜1.36.4.1 全バージョン)。上限: 「宣言した minor を必ず CI で検証する」方針 — 2026-09-22 に Ruby 4.0.7(stable)と Ruby 3.5.0-preview1(3.5 系で公開済みの唯一のリリース)で全 suite / rubocop を実行し緑を確認(matrix に 4.0.7 / 3.5.0-preview1 追加・v0.2.1)。この範囲で公開済みの Ruby は 3.3.x / 3.4.x / 3.5.0-preview1 / 4.0.x のみ(ruby/ruby タグ実測・4.1 系リリースなし)のため宣言範囲 = 検証範囲。RubyGems は version requirement で集合和を表現できないため未検証 minor を区間で挟み込む形(`< 5.0` 等)は取らない。3.5 stable / 4.1 系がリリースされたら matrix を追加して検証(§10 / §13) |
306
+ | Ruby | `>= 3.3, < 4.1`(`!= 4.1.0.preview1` で preview を除外) | 下限: kruby 1.36.x が `required_ruby_version ">= 3.3"` を宣言(RubyGems API で実測 2026-10-18、1.36.0.1〜1.36.5.2 全バージョン)。上限: 「宣言した minor を必ず CI で検証する」方針 — 2026-09-22 に Ruby 4.0.7(stable)と Ruby 3.5.0-preview1(3.5 系で公開済みの唯一のリリース)で全 suite / rubocop を実行し緑を確認(matrix に 4.0.7 / 3.5.0-preview1 追加・v0.2.1)。kruby 1.36.5.2(2026-10-17 公開)でも 3.3.8 / 3.5.0-preview1 / 4.0.7 を再実測(#41)。**Ruby 4.1.0-preview1(2026-10-17 公開)は `!= 4.1.0.preview1` で区間から除外**(PR #42 レビュー指摘・Gem::Requirement の conjunctive `!=` で単一バージョン除外が可能 — 集合和は不要。実測: 4.1.0.preview1 = 不許可 / 4.0.7・3.5.0.preview1 = 許可 / 4.1 = 不許可。Gem.ruby_version は preview でも prerelease 形式 `4.1.0.preview1` を報告するため `< 4.1` 単独では除外されない実測)。matrix に追加できない理由: preview1 環境で `bundle install` が RubyGems/Bundler 既知の `uninitialized constant Pathname::SEPARATOR_PAT`(Bundler 4.0.23 でも再現)で失敗・ローカル実測 2026-10-17。rubygems の修正後または 4.1 stable リリース時に除外を外して matrix 追加。**注意: `!=` は 1 バージョンのみ除外** — 将来の preview(4.1.0.preview2 等)は再び区間内に現れるためその都度 `!=` を追加するか 4.1 stable 時に上限を `< 4.2` に上げる。3.5 stable / 4.1 stable がリリースされたら matrix を追加して検証(§10 / §13) |
297
307
  | `kruby` | `~> 1.36.0` | consumer アプリと同一 pin。`~> 1.36.0` は 1.36.x のみ許可(`~> 1.36` 形式は 1.37 以降も許容してしまうため使用しない)。新しめの kruby に対応する場合は §7 の確認事項(client.rb の `call_api` 依存箇所・K1 橋渡し・`default_config` 探索順序)を済ませてから明示的に上げ替える |
298
308
  | `activesupport` | **任意**(`>= 7.0`) | `defined?(ActiveSupport::Notifications)` でガード(計測のみ、§8)。Rails 無し環境(Cron スクリプト等)でも動作する必要がある — レスポンスの文字列キー化(K2)はこれに依存せず、gem 内部の純 Ruby 変換で担う(§5.2) |
299
309
  | `rspec` / `rubocop` | 開発依存 | spec / lint |
@@ -311,7 +321,10 @@ end
311
321
  `resource.rb` は
312
322
  `K8sRails.client.get(group, version, ns, plural, name)` のような **gem 内部 API** だけを使う。
313
323
  - kruby 上げ替え時の作業は (1) `ApiClient#call_api` のシグネチャ・`opts` 形式(header_params /
314
- query_params / body / auth_names / return_type)の確認、(2) K1 橋渡しの要否確認、
324
+ query_params / body / auth_names / return_type)の確認、(2) K1 橋渡しの要否と機構の確認
325
+ (kruby 1.36.x 依存の事実: `auth_settings` が `BearerToken` を読む・in-cluster の更新フックが
326
+ `api_key_with_prefix("authorization")` 呼び出し時のみ発火する — #29 のブリッジは `auth_settings`
327
+ 呼び出しごとに同期する設計のため、両事実が崩れた場合は再設計)、
315
328
  (3) **`Kubernetes::Configuration.default_config` の探索順序確認**
316
329
  (kruby 1.36.x の loader 実装順は **KUBECONFIG → `~/.kube/config` → in-cluster** で in-cluster が
317
330
  最後。README / 設計書 / 設定コメントがこの順序を明記しているため、loader が変わった場合は
@@ -332,6 +345,19 @@ k8s-rails.request payload: { operation: :list, group:, version:, plural:, names
332
345
  `operation` は **symbol**。例外は notification を発した上で **そのまま raise**
333
346
  される(計測は swallow しない)。
334
347
 
348
+ - **API 処理は `ActiveSupport::Notifications.instrument` のブロック内で実行される**(#32)。
349
+ 標準の開始・終了時刻が実処理を挟むため、標準の
350
+ `ActiveSupport::Notifications::Event#duration` が実処理時間を反映する(旧実装は
351
+ 実処理の後にブロックなしで発行していたため標準 duration はほぼ 0 だった)。購読者が
352
+ 受信するのは finish イベント(裸名 `k8s-rails.request` を購読した場合、1 呼び出しに
353
+ つき 1 イベント — AS 8.1.3.1 実測)で、`status` / `duration_ms` はブロックの
354
+ `ensure` 内で設定されるため finish イベントの payload に常に含まれる。
355
+ 失敗時は `status` に加えて ActiveSupport 標準の `exception`
356
+ (`[クラス名, メッセージ]`)と `exception_object`(例外オブジェクト・再送出される
357
+ 元例外と同一)が payload に付与される。K8sRails 例外階層外の例外
358
+ (プログラミングエラー等)も `status` フォールバック `"api_error"` と標準メタデータの
359
+ 両方が付く。
360
+
335
361
  - Rails アプリではこの notification を `ActiveSupport::Notifications` /
336
362
  `log_subscription` で拾える(ログ・ダッシュボード表示)。
337
363
  - ActiveSupport 無い環境(または `instrumentation: false`)では no-op。
@@ -345,7 +371,7 @@ k8s-rails.request payload: { operation: :list, group:, version:, plural:, names
345
371
  | 設定 | spec 間で `K8sRails.reset!` | 宣言の破棄・再接続 |
346
372
  | 集積(任意) | GitHub Actions で **kind**(または既存 microk8s に接続するジョブ)で実クラスタ E2E | v0.1 の必須ではない。**推奨**: consumer アプリ移行時の検証を兼ねる |
347
373
 
348
- - 本設計では CI は `rspec` + `rubocop` のみを必須とし、kind E2E は v0.4 で
374
+ - 本設計では CI は `rspec` + `rubocop` のみを必須とし、kind E2E は v0.5 で
349
375
  GitHub Actions の追加として扱う(実クラスタへの接続 CI はネットワーク依存のため採用しない)。
350
376
 
351
377
  ## 10. リリース計画
@@ -355,8 +381,9 @@ k8s-rails.request payload: { operation: :list, group:, version:, plural:, names
355
381
  | **v0.1** | §5 の公開 API(CRD 宣言 / list / find / create / patch / 例外 / 計測 / スタブテスト)+ README | rspec 全緑 + **consumer アプリの K8s サービスを `k8s-rails` に移行して動作確認**(§12) |
356
382
  | **v0.2** | #16–#19 の公開 API 拡充・修正(cluster-scoped CRD `scope:` + `*_cluster` メソッド、`configure` のアトミック契約、`connected?` の注入契約、kruby loader 探索順序の修正)。設計書 v0.1.12(案) | rspec 全緑(クラスタ不要)+ 公開 gem push(v0.1.0 と同導線) |
357
383
  | **v0.3** | unified REST transport(`ApiClient#call_api` 上の統一 REST 層)で**対応リソースを全リソース(core v1 built-in 含)に拡大**+`delete` / `delete_cluster` 追加(CRUD 完成)+README QuickStart 改稿(Pod / Deployment CRUD を主例)。設計書 v0.1.15(案) | rspec 全緑(クラスタ不要)+実クラスタ E2E(Pod / Deployment / Node 読み取り・ConfigMap create/patch/delete・readonly ゲート)+公開 gem push(v0.1.0 と同導線) |
358
- | v0.4 | watch(`watch` メソッド、kruby の watch サポート上)、kind E2E の CI 化、Ruby 3.5 stable / 4.1 系の matrix 追加(リリースされたら検証の上追加。Ruby 3.5 preview / 4.0 は v0.2.1 で対応済み)、リトライポリシー | v0.3 運用のフィードバック |
359
- | v0.5 | (展望)複数クラスタ(ネームスペース化された client 集合) | — |
384
+ | **v0.4** | post-v0.3 バッチ: #28(対象名検証による一括削除防止 — `find` / `patch` / `delete`(cluster 版を含む)が `nil` / 空文字列 / `.` / `..` を通信前に `ArgumentError` で拒否)、#29(K1 ブリッジを全リクエストで再同期 — in-cluster ServiceAccount トークンローテーション反映・app の明示設定は上書きしない)、#30(kubeconfig 読み込み失敗を `Unavailable` に統一 — 元例外を `cause` 保持)、#31(`connected?` が Resource 操作と同一キャッシュ接続を検査・初回解決 + K1 ブリッジ + transport 生成を直列化)、#32(通知を ActiveSupport ブロック形式に移行 — 標準 `exception` / 実処理時間)、#33(**既定 HTTP タイムアウト 30 秒 — 挙動変更**。明示値は保持・`http_timeout = 0` でオプトアウト)、#41(kruby 1.36.5.2 実測検証・Ruby 4.1.0-preview1 を宣言範囲から `!=` で除外)。設計書 v0.1.23(案) | rspec 全緑(クラスタ不要)+README 実行例の実クラスタ検証(#33・#41)+公開 gem push(v0.1.0 と同導線) |
385
+ | v0.5 | watch(`watch` メソッド、kruby の watch サポート上)、kind E2E の CI 化、Ruby 3.5 stable / 4.1 stable の matrix 追加(stable リリースされたら検証の上追加。Ruby 3.5 preview / 4.0 は v0.2.1 で対応済み。4.1.0-preview1 はツールチェーン不具合(Bundler `Pathname::SEPARATOR_PAT`)で保留・宣言範囲から `!=` 除外済み — #41)、リトライポリシー | v0.4 運用のフィードバック |
386
+ | v0.6 | (展望)複数クラスタ(ネームスペース化された client 集合) | — |
360
387
 
361
388
  v0.1 の milestone 分割(開発セッション向けのタスク単位目安):
362
389
 
@@ -373,9 +400,16 @@ v0.1 の milestone 分割(開発セッション向けのタスク単位目安
373
400
  形で、ポーリング置き換えのユースケース(consumer アプリの 30 秒ポーリング等)から設計する。
374
401
  - **複数クラスタ**: `K8sRails.cluster("prod") { ... }` のような名前付き client 集合。
375
402
  現時点で需要がないため v0.1 では単一クラスタ。
376
- - **retries / timeout**: kruby の `Kubernetes::Configuration` には接続タイムアウトが
377
- 設定できる。v0.1 は既定値 + README 記載のみで、gem 独自のバックオフは持たない
378
- (Rails 側の middleware / sidekiq retry で吸収するのが慣習)。
403
+ - **retries / timeout**: **timeout は実装済み(#33)** — kruby 1.36.x 既定の
404
+ `timeout = 0`(libcurl 上**無制限待ち**)に対し、gem は接続解決時に
405
+ `config.http_timeout`(既定 30 秒)を未設定のときのみ適用し、明示設定は
406
+ 上書きしない(§5.1 / §5.2 / README)。
407
+ **リトライ**: gem 独自のバックオフは持たない(Rails 側の middleware /
408
+ sidekiq retry で吸収するのが慣習)。kruby の `retry_configuration`
409
+ (`max_retries > 0` で 429 / 5xx を指数バックオフで再試行)は利用者側の
410
+ 表面で、README に**総待ち時間が `max_retries` に依存する**点を含めて説明
411
+ 済み(タイムアウト自体は kruby 実装上リトライ対象外 — `retry_request?` は
412
+ `timed_out?` で即 false)。
379
413
  - **OpenTelemetry**: `instrumentation` を notification 経由にしているため、
380
414
  OTel instrumentation を別途足せる状態に留める(v0.1 で実装しない)。
381
415
 
@@ -426,17 +460,31 @@ PR の差分を最小化)。
426
460
  gemspec の宣言範囲(`>= 3.3, < 4.1`)で公開済みの Ruby を matrix で検証:
427
461
  3.3.0(下限・kruby 1.36.x の `>= 3.3`)・3.3.8(開発)・
428
462
  3.4.10(3.4 系の最新 stable)・3.5.0-preview1(3.5 系の唯一の公開
429
- リリース・2026-09-22 にローカルで全 suite / rubocop 検証済み)・
463
+ リリース・2026-09-22 にローカルで全 suite / rubocop 検証済み、
464
+ kruby 1.36.5.2 で 2026-10-18 再検証)・
430
465
  4.0.7(4.0 系の最新 stable・2026-09-22 にローカルで全 suite /
431
- rubocop 検証済み)。
432
- **宣言範囲を常に matrix がカバーする**こと(`< 4.1` 上限により
433
- 4.1 系以降のリリースは宣言範囲外。RubyGems は version requirement で
434
- 集合和を表現できないため、未検証 minor を区間で挟み込む形
435
- (`< 5.0` 等)は取らない — matrix 追加と範囲拡大をセットで行う。
466
+ rubocop 検証済み、kruby 1.36.5.2 で 2026-10-18 再検証)。
467
+ **Ruby 4.1.0-preview1**(2026-10-17 公開・ruby/ruby タグ実測)は
468
+ `!= 4.1.0.preview1` で**宣言区間から除外**(#41・PR #42 レビュー指摘):
469
+ Gem.ruby_version は preview でも prerelease 形式(実測:
470
+ 4.1.0-preview1 → `4.1.0.preview1`)を報告するため `< 4.1` 単独では
471
+ 除外されない。conjunctive `!=` で名指し除外する(実測:
472
+ 4.1.0.preview1 = 不許可 / 4.0.7・3.5.0.preview1 = 許可 / 4.1 = 不許可)。
473
+ matrix に追加できない理由: preview1 環境では `bundle install` が
474
+ RubyGems/Bundler 既知の
475
+ `uninitialized constant Pathname::SEPARATOR_PAT`(Bundler 4.0.23 でも
476
+ 再現・ローカル実測 2026-10-17)で失敗し、検証基盤が動かない。
477
+ rubygems の修正後または 4.1 stable リリース時に除外を外して
478
+ matrix へ追加する。`!=` は名指した 1 バージョンのみ除外するため、
479
+ 将来の preview(4.1.0.preview2 等)はその都度 `!=` を追加する。
480
+ **宣言範囲を常に matrix がカバーする**こと(RubyGems の version
481
+ requirement 配列は conjunctive(AND)で `!=` による単一バージョン除外が
482
+ 可能。集合和は不要。未検証 minor を区間で挟み込む形(`< 5.0` 等)は
483
+ 取らない — matrix 追加と範囲拡大をセットで行う。
436
484
  3.5 stable / 4.1 系等 stable 化された新リリースが出たら
437
485
  matrix への追加を忘れないこと)。
438
486
  テストはクラスタ不要(§9・スタブ注入)のため v0.1 は runner 上のユニットのみ。
439
- kind / 実クラスタ E2E の CI 化は v0.4 対象(§9・§10)
487
+ kind / 実クラスタ E2E の CI 化は v0.5 対象(§9・§10)
440
488
  - `README` に「kruby pin」「対応 k8s バージョン(実測 v1.33.x で検証済み)」「K1 橋渡しの背景」
441
489
  を明記する(検索でヒットする重要な注意点のため)
442
490
 
@@ -461,3 +509,10 @@ PR の差分を最小化)。
461
509
  | 0.1.14 | 2026-09-22 | **対応リソースの拡大: unified REST transport 導入で core v1 built-in(Pod / Service / ConfigMap / Node 等)に対応**(旧 `CustomObjectsApi` 経路では `group: ""` が 404 だった唯一のギャップ。named built-in は従来から動作)。①transport を `Kubernetes::ApiClient#call_api` 上の統一 REST 層に改訂(`client.rb`。パス構築: core `/api/v1/...` / named `/apis/{group}/{version}/...`・CGI escape・json-patch Content-Type 維持)②`delete` / `delete_cluster` を追加(CRUD 完成。readonly ゲート同型。Status オブジェクトを文字列キーで返す)③テスト注入スタブの契約を `call_api` の 1 メソッドに統一(§5.1 表 / configuration.rb コメント / §9)④実クラスタ(v1.33.13)で Pod list / Deployment find / ConfigMap create・patch・delete / Node list_cluster を E2E 検証 | 実装反映済み |
462
510
  | 0.1.15 | 2026-09-22 | v0.3.0 リリース準備: ①`VERSION` を 0.2.1 → **0.3.0**(version.rb + version spec)②CHANGELOG の `Unreleased` を **0.3.0** に確定③README の install pin(`~> 0.2` → `~> 0.3`)・`VERSION` 例・Roadmap 見出し(v0.3+ → v0.4+)を同期④§10 リリース計画表で v0.3 行を「実際にリリースした内容」に書き換え(unified REST transport / delete / README 改稿)、旧 v0.3 予定内容(watch / kind E2E / matrix 追加)を v0.4 へ、リトライポリシーを v0.4 へ、複数クラスタ展望を v0.5 へ移動⑤§2.2 / §9 / §13 の旧「v0.3 対象」参照を v0.4 に修正 | 実装反映済み |
463
511
  | 0.1.16 | 2026-09-22 | v0.3.1 リリース準備: gemspec の `summary` / `description` 文言更新(PR #25)を**公開 gem として反映するための docs みのパッチリリース**。公開済み gem の文言は公開後に変更できない(RubyGems に gem メタの編集 API / UI なし)ため、0.3.1 の公開で RubyGems ページ表示が新文言に切り替わる。①`VERSION` を 0.3.0 → **0.3.1**(version.rb + version spec)②CHANGELOG に 0.3.1 セクション追加③README の `VERSION` 例・§3 ツリーコメントを同期。**コード・挙動の変更なし** | 実装反映済み |
512
+ | 0.1.17 | 2026-10-13 | Issue #28–#29 対応(両方 priority:high・v0.4 予定): ①#28 `find` / `patch` / `delete`(cluster 版を含む)が無効な対象名(`nil` / 空文字列 / `.` / `..`)を**通信前に** `ArgumentError` で拒否(従来 `delete(nil)` は Delete Collection URL を生成し一括削除リスク、`.` / `..` は HTTP クライアントのパス正規化で送信先が変化。ゲート順は scope → readonly → 名検証・計測より前)。§5.3 / README に明記②#29 K1 ブリッジを「初回コピー」から「全 `auth_settings` 呼び出し(全リクエスト)で再同期」に改訂: kruby 1.36.x の in-cluster 更新フックは `api_key_with_prefix("authorization")` 呼び出し時のみ発火するため、初回コピー固定では ServiceAccount トークンローテーションが反映されず長時間稼働プロセスで 401 が続く問題を実修正。解決済み config の singleton class に sync モジュールを prepend。ユーザー明示設定の `BearerToken` はブリッジ・上書きしない(区別マーカー付き)。§5.2 / §7(上げ替えチェックリスト)/ README を同期③PR #36 レビュー対応: (a) 送出トークンを「共有 `api_key` 書き戻し後 `super` が再読みなし」に変更 — `super` が先に per-call settings を構築し、当該呼び出しで新規取得した `authorization` 値をその戻り値ハッシュに直接書き込む(スレッド環境で並行リクエストの stale 書き込みが挟んでも stale トークンを送出しない)(b) ブリッジが最後に書いた値を追跡(`@k8s_rails_bridged_bearer_value`)し、app が client 生成後に `BearerToken` を置き換えた場合は同期を恒久的に停止して app 側の値を権威とする(「上書きしない」保証を生成後の上書きまで拡張)| 実装反映済み |
513
+ | 0.1.18 | 2026-10-14 | Issue #32 対応: `k8s-rails.request` 通知を **ActiveSupport ブロック形式に移行**(API 処理を `ActiveSupport::Notifications.instrument` のブロック内で実行)。①標準 start/finish が実処理を挟み `Event#duration` が実処理時間を反映(旧実装は実処理後に発行し標準 duration はほぼ 0 だった)②失敗時に標準 `exception` / `exception_object` メタデータが付与(`status` / `duration_ms` はブロック `ensure` で設定され finish イベントに常に含まれる。元例外はそのまま再送出)③既存 payload 契約(operation / 座標 / duration_ms / status 列挙)・no-op・戻り値契約は不変。AS 8.1.3.1 実測: 裸名購読者は finish イベント 1 件のみ受信(§8)。※版数 0.1.17 は PR #36(#28/#29)が使用 | 実装反映済み |
514
+ | 0.1.19 | 2026-10-14 | Issue #30 対応: 接続設定の解決・クライアント生成段階(`Client.build` / `connected?` → `build_configuration`)での `Kubernetes::ConfigError` を `K8sRails::Unavailable` に**統一**(元例外を `cause` に保持 — rescue 内 raise の自動 cause。通信時の adapter / probe 変換と同一契約で、raw `ConfigError` は公開されない)。§5.4 変換規則表に ConfigError 行を追加・§5.4 例外ツリーの説明を同期・README 例外ツリーも同期。rescue は `ConfigError` のみの狭い形で、`NoMethodError` 等のプログラミングエラーは変換せず伝播。回帰テスト: 不正 context(kubeconfig の `current-context: missing`)で `Resource.list` / `connected?` の双方が `Unavailable` + `cause` = ConfigError | 実装反映済み |
515
+ | 0.1.20 | 2026-10-14 | Issue #31 対応: `connected?` が **Resource 操作と同じキャッシュ済み接続を検査**するように改訂 — 解決済み config を cached transport の寿命とともに memoize(`@resolved_connection_config`)し、`Client.build` と `connected?` の双方が同一解決結果を使用。旧実装は `connected?` が毎回 `build_configuration` を再解決し、設定解決結果が変わった場合にプローブは新しい接続 (B) を検査する一方 Resource 操作は旧接続 (A) を使い続ける不整合があった。暗黙の再解決はせず、明示的な再読み込み経路は `reset!`(§5.1)。解決失敗時は memoize せず次回再試行。§5.2(build 手順・connected? 解決順序)と README Connectivity check を同期。回帰テスト: `default_config` が接続 A → B の順で返す環境で (1) probe が A に対して発行されること(B 実測 0 リクエスト・2 つのローカル HTTP サーバで実測)、(2) `reset!` 後に B へ切り替わること。④PR #39 レビュー対応(Codex P2 + Copilot 同一指摘): memoize(`||=`)は非アトミックで、スレッド環境で初回 Resource 操作と初回 `connected?` が並行すると双方が `build_configuration` を実行し後書き勝ちで再び接続不一致を起こし得る → `Client::BUILD_LOCK`(Mutex)を導入し、初回の config 解決・K1 橋渡し・transport 生成を同一クリティカルセクションに閉じた(プローブの HTTP I/O はロック外)。`reset!` も同一ロックで build との交錯を防ぐ。決定論的並行回帰テスト: 解決スタブが入口を観測できるよう待ち合わせし、(a) 解決呼び出しが厳密に 1 回(ロック待ちの 2 スレッド目は入口に入れない)(b) op/probe がともに接続 A へ発行(B 実測 0 リクエスト)を検証 — 修正前 lib でこの spec は失敗(revert-proof) | 実装反映済み |
516
+ | 0.1.21 | 2026-10-15 | Issue #33 対応: **既定の HTTP タイムアウト導入(挙動変更・#33)**。kruby 1.36.x の既定 `timeout = 0` は libcurl 上**無制限待ち**で、応答しない API が Rails のリクエスト / ジョブを占有し得る。`config.http_timeout`(既定 30 秒)を新設し、接続解決時に解決済み接続の `timeout` が未設定値 `0` のときのみ適用(明示設定は上書きしない。`http_timeout = 0` で無制限にオプトアウト — kruby 側 `0` は未設定と区別できないためオプトアウトは gem 側キーで行う。適用は冪等)。`Resource` 操作と `connected?` の双方に同一適用(§5.2 共通経路)。非負 Integer / `nil` 以外は `ArgumentError`(Float は libcurl で `0`=無制限に丸められるため fail-fast)。README に実行可能な設定例(kruby 読み込み含む)・kruby 既定 `0` = 無制限であること・kruby `retry_configuration` 使用時の総待ち時間が `max_retries` に依存する点(タイムアウト自体は kruby 実装上リトライ対象外)を追記。§11 の「retries / timeout」検討事項を実装済み方針に更新。実測: 3 秒遅延ループバックに対し `timeout = 1` で Resource 操作と `connected?` が双方 ~1.01 秒で `Unavailable`。※版数 0.1.20 はオープン PR #39(#31)が使用(並行 PR の事前割当規約) | 実装反映済み |
517
+ | 0.1.22 | 2026-10-18 | Issue #41 対応: kruby 1.36.5.2(2026-10-17 公開)への追従検証 — §6 pin(`~> 1.36.0`)は 1.36.5.2 を既に許容するため **pin 変更なし**。1.36.2.1 と 1.36.5.2 の gem lib 差分を実測で確認(version.rb の VERSION 文字列のみ・kruby CHANGELOG の記載と一致し OpenAPI・挙動契約は不変)→ §7 の 3 事実(call_api シグネチャ / K1 橋渡し事実 / loader 探索順序)は lib 同一のため機械的に成立。全 suite(132 examples / 0 failures・22 files no offenses)を Ruby 3.3.8 / 3.5.0-preview1 / 4.0.7 で再実測。kruby 1.36.5.2 の新ランタイム依存 `logger`(Ruby 4.0 での bundled gem 昇格対応)は transitive で解決・consumer 変更不要。matrix 未追加の理由(preview1 環境で `bundle install` が RubyGems/Bundler 既知の `uninitialized constant Pathname::SEPARATOR_PAT`(Bundler 4.0.23 でも再現)で失敗・ローカル rbenv 4.1.0-preview1 実測)は gemspec / README / §6・§13 / CI コメントに記録。**PR #42 レビュー指摘で修正**: 初版の「RubyGems は集合和を表現できないため除外不能」は誤り — `Gem::Requirement` の conjunctive `!=` で単一バージョン除外は可能(実測: 4.1.0.preview1 不許可 / 4.0.7・3.5.0.preview1 許可 / 4.1 不許可)で、gemspec を `">= 3.3"` / `"< 4.1"` / `"!= 4.1.0.preview1"` の 3 件の conjunctive requirement に改訂。Gem.ruby_version は preview でも prerelease 形式(`4.1.0.preview1`)を報告し `< 4.1` 単独では除外されない実測も追記。`!=` は 1 バージョンのみ除外のため将来の preview はその都度追加または 4.1 stable 時に上限 `< 4.2` へ。§6 / §10 / §13 / gemspec / README / CI コメント / CHANGELOG を同期 | 実装反映済み |
518
+ | 0.1.23 | 2026-10-19 | v0.4.0 リリース準備: ①`VERSION` を 0.3.1 → **0.4.0**(version.rb + version spec。#33 が既定タイムアウト導入という挙動変更を含むため minor バンプ — patch ではない)②CHANGELOG の `Unreleased`(#28–#33・#41 分)を **0.4.0** に確定(タグ時点で Unreleased バケットを残さない)③README の install pin(`~> 0.3` → `~> 0.4`)・`VERSION` 例・Roadmap 見出し(v0.4+ → v0.5+)同期と Ruby 4.1 stable 時の解除手順(`!=` 除外の削除・`< 4.2` 拡張・matrix 追加)追記④§10 リリース計画表で v0.4 行を「実際にリリースした内容」(#28–#33・#41)に書き換え、旧 v0.4 予定内容(watch / kind E2E CI 化 / matrix 追加 / リトライポリシー)を v0.5 へ、複数クラスタ展望を v0.6 へ移動⑤§2.2 / §9 / §13 / test.yml コメントの旧版参照を v0.5 に修正(test.yml コメントは v0.3 表記のまま残存していたため併せて修正) | 実装反映済み |
data/lib/k8s-rails.rb CHANGED
@@ -80,17 +80,24 @@ module K8sRails
80
80
  # The notification payload is
81
81
  # { operation:, group:, version:, plural:, namespace:, duration_ms:,
82
82
  # status: "ok" | "unavailable" | "api_error" }.
83
- # The block's return value always flows through unchanged. No-op when
84
- # `config.instrumentation` is false or ActiveSupport is not loaded.
83
+ # The API call runs INSIDE the ActiveSupport notification block (#32), so
84
+ # the standard start/finish times bracket the real processing
85
+ # (`Event#duration` measures it) and failures carry the standard
86
+ # `exception` / `exception_object` metadata alongside the custom `status`.
87
+ # `status` / `duration_ms` are set in the block's `ensure` so the finish
88
+ # event already carries them. The block's return value always flows
89
+ # through unchanged. No-op when `config.instrumentation` is false or
90
+ # ActiveSupport is not loaded.
85
91
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- fixed §8 wrapper
86
92
  def instrument(operation, metadata)
87
93
  return yield unless instrumentation_enabled?
88
94
 
89
95
  payload = { operation: operation }.merge(metadata)
90
96
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
- begin
97
+ ActiveSupport::Notifications.instrument("k8s-rails.request", payload) do
92
98
  result = yield
93
99
  payload[:status] = "ok"
100
+ result
94
101
  rescue K8sRails::Unavailable
95
102
  payload[:status] = "unavailable"
96
103
  raise
@@ -101,12 +108,11 @@ module K8sRails
101
108
  # Fallback for exceptions outside the K8sRails hierarchy (e.g. a
102
109
  # programming error like NoMethodError from a malformed stub): keep
103
110
  # the documented status enum (ok/unavailable/api_error) intact while
104
- # re-raising the original exception.
111
+ # re-raising the original exception (ActiveSupport attaches its
112
+ # standard `exception` / `exception_object` metadata on this path).
105
113
  payload[:status] ||= "api_error"
106
114
  payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1_000).round(2)
107
- ActiveSupport::Notifications.instrument("k8s-rails.request", payload)
108
115
  end
109
- result
110
116
  end
111
117
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
112
118
 
@@ -34,6 +34,14 @@ module K8sRails
34
34
  # `Kubernetes::Configuration` and builds an in-memory `ApiClient`. The first
35
35
  # real call is where DNS/timeout/TLS can fail.
36
36
  class Client
37
+ # Serializes the first connection resolution + transport build (and
38
+ # `reset!`) so a concurrent Resource operation and `connected?` probe
39
+ # cannot resolve DIFFERENT configs (kubeconfig changing between the two
40
+ # resolutions would recreate the #31 probe/operation mismatch in a
41
+ # threaded process — `||=` alone is not atomic). The probe's HTTP I/O
42
+ # runs OUTSIDE the lock: only first-time resolution pays for it.
43
+ BUILD_LOCK = Mutex.new
44
+
37
45
  class << self
38
46
  # Return the shared transport, building it on first call (lazy connect).
39
47
  #
@@ -43,16 +51,20 @@ module K8sRails
43
51
  # 1. `config.connection` if set, else `Kubernetes::Configuration.default_config`
44
52
  # 2. K1 bridge: duplicate `api_key['authorization']` into
45
53
  # `api_key['BearerToken']` (kruby 1.36 in-cluster/KUBECONFIG write the
46
- # token under 'authorization' but auth_settings reads 'BearerToken').
54
+ # token under 'authorization' but auth_settings reads 'BearerToken'),
55
+ # and keep the bridge in sync on every `auth_settings` call so a
56
+ # rotated ServiceAccount token is picked up (#29).
47
57
  # 3. Build `ApiClient` (in-memory, no I/O).
48
58
  # 4. Wrap in a StringKeyedAdapter that normalizes responses (K2) and
49
59
  # converts kruby errors to K8sRails exceptions.
50
60
  def build
51
- @build ||= if (injected = K8sRails.config.api_client)
52
- StringKeyedAdapter.new(injected)
53
- else
54
- StringKeyedAdapter.new(build_api_client)
55
- end
61
+ BUILD_LOCK.synchronize do
62
+ @build ||= if (injected = K8sRails.config.api_client)
63
+ StringKeyedAdapter.new(injected)
64
+ else
65
+ StringKeyedAdapter.new(build_api_client)
66
+ end
67
+ end
56
68
  end
57
69
 
58
70
  # Lightweight connectivity probe (design §5.2). Performs one lightweight
@@ -67,16 +79,24 @@ module K8sRails
67
79
  def connected?
68
80
  return true if K8sRails.config.api_client
69
81
 
70
- config = build_configuration
71
- # The probe also authenticates — apply the K1 bridge or the Authorization
72
- # header would be empty on clusters where /version requires auth.
73
- bridge_bearer_token(config)
82
+ # Resolution + K1 bridge happen under BUILD_LOCK (one critical section
83
+ # with the transport build); only the probe's HTTP I/O stays outside.
84
+ config = BUILD_LOCK.synchronize do
85
+ resolved = resolved_connection_config
86
+ bridge_bearer_token(resolved)
87
+ resolved
88
+ end
74
89
  VersionApiProbe.new(config).probe
75
90
  end
76
91
 
77
- # Reset cached connection + transport (test support, §5.1).
92
+ # Reset cached connection + transport (test support, §5.1). Also drops
93
+ # the cached resolved config so the next build/probe re-resolves (#31).
94
+ # Takes BUILD_LOCK so a reset cannot interleave with a build.
78
95
  def reset!
79
- @build = nil
96
+ BUILD_LOCK.synchronize do
97
+ @build = nil
98
+ @resolved_connection_config = nil
99
+ end
80
100
  end
81
101
 
82
102
  # Convert a kruby ApiError to the K8sRails exception tree (K3, §5.4).
@@ -105,26 +125,172 @@ module K8sRails
105
125
  end
106
126
 
107
127
  # Build a lazy in-memory ApiClient from the resolved configuration.
128
+ # Runs while BUILD_LOCK is held (from `build`) so the transport and the
129
+ # cached config are selected in one critical section.
108
130
  def build_api_client
109
- config = build_configuration
131
+ config = resolved_connection_config
110
132
  bridge_bearer_token(config)
111
133
  Kubernetes::ApiClient.new(config)
112
134
  end
113
135
 
136
+ # The resolved connection config, cached for the lifetime of the cached
137
+ # transport (#31): `connected?` and Resource operations MUST inspect /
138
+ # use the SAME connection — re-resolving the config would let the probe
139
+ # validate connection B while ops still send to connection A. MUST be
140
+ # read/written while holding BUILD_LOCK so a concurrent first build and
141
+ # first probe resolve the config EXACTLY ONCE (review: `||=` alone is
142
+ # not atomic). There is no implicit reload; `reset!` (test support,
143
+ # §5.1) is the explicit reload path. Cached only on success — a failed
144
+ # resolution (e.g. a broken kubeconfig) is re-attempted on the next call.
145
+ def resolved_connection_config
146
+ @resolved_connection_config ||= build_configuration
147
+ end
148
+
149
+ # Resolve the connection configuration. The kubeconfig load happens HERE
150
+ # (kruby's default_config reads KUBECONFIG -> ~/.kube/config ->
151
+ # in-cluster, design §5.2), so a broken kubeconfig / missing context
152
+ # surfaces at build time — BEFORE any request. Convert it to the public
153
+ # exception tree with the original error as `cause` (#30), matching the
154
+ # transport-time conversion in StringKeyedAdapter#request /
155
+ # VersionApiProbe#probe. The rescue is deliberately narrow: programming
156
+ # errors must not be swallowed (design §5.4).
157
+ #
158
+ # The resolved configuration is where the HTTP timeout policy is
159
+ # applied (apply_http_timeout), so Resource operations (build_api_client)
160
+ # and the `connected?` probe carry the SAME value (#33).
114
161
  def build_configuration
115
- K8sRails.config.connection || Kubernetes::Configuration.default_config
162
+ config = K8sRails.config.connection || Kubernetes::Configuration.default_config
163
+ apply_http_timeout(config)
164
+ config
165
+ rescue Kubernetes::ConfigError => e
166
+ raise Unavailable, "K8s に接続できません: #{e.message}"
167
+ end
168
+
169
+ # HTTP timeout policy (design §5.1 / #33). kruby 1.36.x defaults
170
+ # `Configuration#timeout` to 0, which means NO timeout — an
171
+ # unresponsive API would hold a Rails request / job forever. Apply the
172
+ # configured `http_timeout` (K8sRails::Configuration, default 30s) ONLY
173
+ # when the resolved configuration still carries kruby's unset value
174
+ # (0): an explicitly set `config.connection.timeout` (any non-zero
175
+ # value) is never overridden. `http_timeout = 0` opts out entirely and
176
+ # restores kruby's unlimited default — kruby's own `timeout = 0` is
177
+ # indistinguishable from "never set", so it is treated as unset and the
178
+ # opt-out lives on the K8sRails knob instead.
179
+ #
180
+ # Idempotent: after the first application the resolved timeout is
181
+ # non-zero, so later resolutions skip (the value reads as explicit).
182
+ #
183
+ # The "unset" test is kruby's own 0, compared in its ORIGINAL type —
184
+ # never via to_i coercion, which would read an explicit fractional
185
+ # value (0.5) as unset and overwrite it. A non-Numeric (or nil)
186
+ # connection timeout is likewise treated as explicitly set and
187
+ # preserved; the Integer restriction is the http_timeout knob's own
188
+ # contract (validate_http_timeout!), not the connection's.
189
+ def apply_http_timeout(config)
190
+ requested = K8sRails.config.http_timeout
191
+ validate_http_timeout!(requested)
192
+ return if requested&.zero? || !connection_timeout_unset?(config)
193
+
194
+ config.timeout = requested || Configuration::DEFAULT_HTTP_TIMEOUT
195
+ end
196
+
197
+ # True only when the resolved connection's timeout still carries
198
+ # kruby's unset value — numeric 0, its initialize default. A nil or
199
+ # non-Numeric timeout is never kruby's produced value, so it reads as
200
+ # explicitly set by the app and is preserved untouched.
201
+ def connection_timeout_unset?(config)
202
+ value = config.timeout
203
+ value.is_a?(Numeric) && value.zero?
204
+ end
205
+
206
+ # `http_timeout` accepts nil (built-in default) or a non-negative
207
+ # Integer (0 = opt out, N >= 1 = seconds). Rejecting non-Integers is
208
+ # fail-fast on purpose: a Float like 0.5 would be truncated by
209
+ # typhoeus/libcurl to CURLOPT_TIMEOUT 0 = unlimited — silently
210
+ # recreating the unbounded wait this policy exists to prevent.
211
+ def validate_http_timeout!(value)
212
+ return if value.nil? || (value.is_a?(Integer) && value >= 0)
213
+
214
+ raise ArgumentError,
215
+ "http_timeout must be a non-negative Integer or nil (got #{value.inspect})"
116
216
  end
117
217
 
118
218
  # K1 bridge (kruby 1.36.x quirk): InClusterConfig writes the Bearer token
119
219
  # to `api_key['authorization']` (and the KUBECONFIG path can too), but
120
220
  # `Configuration#auth_settings` reads `api_key['BearerToken']` for the
121
221
  # `Authorization` header. Without the bridge the header is empty → 401.
122
- # Duplicate only when the target key is not already set.
222
+ #
223
+ # The bridge must NOT freeze the first copy (#29): kruby's in-cluster
224
+ # refresh hook only runs inside `api_key_with_prefix("authorization")`
225
+ # (it re-reads the token file once the refresh deadline passes), and
226
+ # normal header generation never queries that key. So after the initial
227
+ # copy, a module is prepended to the resolved config's singleton class
228
+ # that re-syncs the bridged `BearerToken` from the upstream key on every
229
+ # `auth_settings` call — kruby calls it for every request, so a rotated
230
+ # token reaches the header without rebuilding the client.
231
+ #
232
+ # The bridge tracks the value it last wrote
233
+ # (`@k8s_rails_bridged_bearer_value`). If the app replaces
234
+ # `api_key['BearerToken']` after client creation (explicit credential
235
+ # rotation), the current value matches neither the bridge's last write
236
+ # nor the value the bridge is about to write, so the bridge stops
237
+ # syncing and the app's value stays authoritative — a user-set
238
+ # `BearerToken` is never bridged or overwritten, before or after client
239
+ # creation.
123
240
  def bridge_bearer_token(config)
124
- auth = config.api_key["authorization"]
125
- return if auth.nil? || config.api_key.key?("BearerToken")
241
+ return if config.api_key["authorization"].nil?
242
+ return if config.api_key.key?("BearerToken")
243
+ return if config.instance_variable_get(:@k8s_rails_bridged_bearer)
244
+
245
+ initial = config.api_key_with_prefix("authorization")
246
+ config.api_key["BearerToken"] = initial
247
+ config.instance_variable_set(:@k8s_rails_bridged_bearer, true)
248
+ config.instance_variable_set(:@k8s_rails_bridged_bearer_value, initial)
249
+ config.singleton_class.prepend(BridgedBearerSync)
250
+ end
126
251
 
127
- config.api_key["BearerToken"] = auth
252
+ # Prepend target of the K1 bridge (see bridge_bearer_token). kruby calls
253
+ # `auth_settings` for every request; syncing here (not once at build
254
+ # time) is what keeps a rotated in-cluster token fresh.
255
+ #
256
+ # Thread-safety: the token for THIS request is taken from the freshly
257
+ # computed `authorization` value and written into THIS call's returned
258
+ # settings hash directly — it is never read back from the shared
259
+ # `api_key` (where a concurrent request's write could land in between),
260
+ # so concurrent requests in a threaded Rails process cannot send a
261
+ # stale copy. The shared key is still updated as the backing copy for
262
+ # direct readers.
263
+ module BridgedBearerSync
264
+ def auth_settings
265
+ settings = super
266
+ sync_bridged_bearer(settings)
267
+ settings
268
+ end
269
+
270
+ private
271
+
272
+ def sync_bridged_bearer(settings)
273
+ return unless instance_variable_get(:@k8s_rails_bridged_bearer)
274
+
275
+ fresh = api_key_with_prefix("authorization")
276
+ return if fresh.nil?
277
+ return if app_overrode_bridged_token?(fresh)
278
+
279
+ settings["BearerToken"][:value] = fresh
280
+ api_key["BearerToken"] = fresh
281
+ instance_variable_set(:@k8s_rails_bridged_bearer_value, fresh)
282
+ end
283
+
284
+ # The app replaced the bridged value (explicit credential rotation):
285
+ # it matches neither the bridge's last write nor the value written
286
+ # now. Stops the sync so the app's credential stays authoritative.
287
+ def app_overrode_bridged_token?(fresh)
288
+ current = api_key["BearerToken"]
289
+ return false if current == instance_variable_get(:@k8s_rails_bridged_bearer_value) || current == fresh
290
+
291
+ instance_variable_set(:@k8s_rails_bridged_bearer, false)
292
+ true
293
+ end
128
294
  end
129
295
  end
130
296
 
@@ -9,6 +9,12 @@ module K8sRails
9
9
  #
10
10
  # This class is pure data: it never touches the network or kruby.
11
11
  class Configuration
12
+ # 既定の HTTP タイムアウト(秒)。kruby 1.36.x の既定 `timeout = 0` は
13
+ # **無制限待ち**を意味し、応答しない接続が Rails のリクエストやジョブを
14
+ # 占有し得るため(#33)、接続解決時に未設定ならこの値を適用する。
15
+ # `http_timeout = 0` で kruby 既定の無制限に戻せる(オプトアウト)。
16
+ DEFAULT_HTTP_TIMEOUT = 30
17
+
12
18
  # CRD 宣言が namespace 未指定時のデフォルト。
13
19
  attr_accessor :namespace
14
20
 
@@ -18,6 +24,12 @@ module K8sRails
18
24
  # 認証を上書きする場合に指定する。
19
25
  attr_accessor :connection
20
26
 
27
+ # HTTP タイムアウト(秒、§5.1・#33)。接続解決時に、解決済み
28
+ # Kubernetes::Configuration の `timeout` が kruby の未設定値 `0` で
29
+ # あるときのみ適用する。利用者が `config.connection.timeout` に非 0 を
30
+ # 明示した場合は勝手に上書きしない。`0` で無制限にオプトアウト。
31
+ attr_accessor :http_timeout
32
+
21
33
  # テスト専用(§5.1)。kruby の `Kubernetes::ApiClient#call_api` と同型の
22
34
  # `call_api(http_method, path, opts)` メソッドを実装した素のオブジェクトを
23
35
  # 指定すると、Client.build は接続解決をスキープしてこれを内部トランスポート
@@ -32,6 +44,7 @@ module K8sRails
32
44
  def initialize
33
45
  @namespace = "default"
34
46
  @connection = nil
47
+ @http_timeout = DEFAULT_HTTP_TIMEOUT
35
48
  @api_client = nil
36
49
  @instrumentation = true
37
50
  end
@@ -33,7 +33,7 @@ module K8sRails
33
33
 
34
34
  # One object by name. Raises K8sRails::NotFound when absent.
35
35
  def find(name, namespace: resolved_namespace)
36
- run_operation(:find, :namespaced, namespace: namespace) do
36
+ run_operation(:find, :namespaced, namespace: namespace, object_name: name) do
37
37
  transport.get(group_name, version_name, namespace, plural_name, name)
38
38
  end
39
39
  end
@@ -54,7 +54,7 @@ module K8sRails
54
54
 
55
55
  # JSON Patch a named object. Same readonly restriction as `create`.
56
56
  def patch(name, operations, namespace: resolved_namespace)
57
- run_operation(:patch, :namespaced, namespace: namespace, writable: true) do
57
+ run_operation(:patch, :namespaced, namespace: namespace, writable: true, object_name: name) do
58
58
  transport.patch(group_name, version_name, namespace, plural_name, name, operations)
59
59
  end
60
60
  end
@@ -62,7 +62,7 @@ module K8sRails
62
62
  # Delete a named object; returns the API status object. Same readonly
63
63
  # restriction; raises NotFound when already gone.
64
64
  def delete(name, namespace: resolved_namespace)
65
- run_operation(:delete, :namespaced, namespace: namespace, writable: true) do
65
+ run_operation(:delete, :namespaced, namespace: namespace, writable: true, object_name: name) do
66
66
  transport.delete(group_name, version_name, namespace, plural_name, name)
67
67
  end
68
68
  end
@@ -77,7 +77,7 @@ module K8sRails
77
77
 
78
78
  # One cluster-scoped object by name. Raises NotFound when absent.
79
79
  def find_cluster(name)
80
- run_operation(:find, :cluster) do
80
+ run_operation(:find, :cluster, object_name: name) do
81
81
  transport.get_cluster(group_name, version_name, plural_name, name)
82
82
  end
83
83
  end
@@ -98,26 +98,28 @@ module K8sRails
98
98
 
99
99
  # JSON Patch a cluster-scoped object. Same readonly restriction.
100
100
  def patch_cluster(name, operations)
101
- run_operation(:patch, :cluster, writable: true) do
101
+ run_operation(:patch, :cluster, writable: true, object_name: name) do
102
102
  transport.patch_cluster(group_name, version_name, plural_name, name, operations)
103
103
  end
104
104
  end
105
105
 
106
106
  # Delete a cluster-scoped object. Same readonly restriction as `delete`.
107
107
  def delete_cluster(name)
108
- run_operation(:delete, :cluster, writable: true) do
108
+ run_operation(:delete, :cluster, writable: true, object_name: name) do
109
109
  transport.delete_cluster(group_name, version_name, plural_name, name)
110
110
  end
111
111
  end
112
112
 
113
113
  private
114
114
 
115
- # Scope gate + readonly gate (§5.3) + §8 instrumentation for every
116
- # operation. The block value (and any K8sRails exception) flows through
117
- # unchanged. Cluster-scoped operations carry no namespace.
118
- def run_operation(operation, scope, namespace: nil, writable: false, &block)
115
+ # Scope gate + readonly gate (§5.3) + object-name validation (#28) +
116
+ # §8 instrumentation for every operation. The block value (and any
117
+ # K8sRails exception) flows through unchanged. Cluster-scoped
118
+ # operations carry no namespace. Gate order: scope → readonly → name.
119
+ def run_operation(operation, scope, namespace: nil, writable: false, object_name: nil, &block)
119
120
  scope == :cluster ? assert_cluster_scoped : assert_namespaced
120
121
  assert_writable if writable
122
+ assert_object_name(object_name) if %i[find patch delete].include?(operation)
121
123
  ns = scope == :cluster ? nil : namespace
122
124
  K8sRails.instrument(operation, instrument_meta(ns)) { block.call }
123
125
  end
@@ -138,11 +140,21 @@ module K8sRails
138
140
  raise ReadOnlyError, "#{kind_name} is declared readonly — create/patch/delete are disabled (K4)"
139
141
  end
140
142
 
143
+ # Object-scoped operations (find/patch/delete) must reject an invalid
144
+ # target name BEFORE any request is built (#28): a nil name resolves to
145
+ # the collection URL, where a DELETE is a Delete Collection call, and
146
+ # "." / ".." are normalized away by the HTTP client into a different
147
+ # resource path.
148
+ def assert_object_name(name)
149
+ return unless name.nil? || name.to_s.empty? || %w[. ..].include?(name.to_s)
150
+
151
+ raise ArgumentError, "#{kind_name} object name must be a non-empty string (got #{name.inspect})"
152
+ end
153
+
141
154
  def assert_cluster_scoped
142
155
  return if cluster_scoped?
143
156
 
144
- raise ArgumentError,
145
- "#{kind_name} is namespaced — *_cluster methods require a scope: :cluster declaration"
157
+ raise ArgumentError, "#{kind_name} is namespaced — *_cluster methods require a scope: :cluster declaration"
146
158
  end
147
159
 
148
160
  def assert_namespaced
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module K8sRails
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: k8s-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dorian - Takahiro Ishida
@@ -117,6 +117,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
117
117
  - - "<"
118
118
  - !ruby/object:Gem::Version
119
119
  version: '4.1'
120
+ - - "!="
121
+ - !ruby/object:Gem::Version
122
+ version: 4.1.0.preview1
120
123
  required_rubygems_version: !ruby/object:Gem::Requirement
121
124
  requirements:
122
125
  - - ">="