k8s-rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +201 -0
- data/docs/design.md +383 -0
- data/lib/k8s-rails.rb +124 -0
- data/lib/k8s_rails/client.rb +181 -0
- data/lib/k8s_rails/configuration.rb +35 -0
- data/lib/k8s_rails/crd.rb +55 -0
- data/lib/k8s_rails/errors.rb +43 -0
- data/lib/k8s_rails/normalizer.rb +36 -0
- data/lib/k8s_rails/resource.rb +94 -0
- data/lib/k8s_rails/version.rb +5 -0
- metadata +129 -0
data/lib/k8s-rails.rb
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# K8sRails — Kubernetes API / CRD convention layer for Rails applications.
|
|
4
|
+
#
|
|
5
|
+
# Design: docs/design.md (KBR-DESIGN-001)
|
|
6
|
+
#
|
|
7
|
+
# Load-time contract (§3 / §7): requiring this entry point has NO side effects
|
|
8
|
+
# and does NOT load kruby. The kruby-dependent Client (and therefore kruby
|
|
9
|
+
# itself) is loaded lazily on the first `K8sRails::Client.build` /
|
|
10
|
+
# `K8sRails.connected?` call. This lets the gem be `require`d even when no
|
|
11
|
+
# cluster is reachable and keeps kruby's load cost out of app boot.
|
|
12
|
+
module K8sRails
|
|
13
|
+
# Lazy load (§7): referencing K8sRails::Client (e.g. Client.build) is the
|
|
14
|
+
# moment kruby is required — never at gem require time. Direct constant
|
|
15
|
+
# access works, while a plain `require "k8s-rails"` stays kruby-free.
|
|
16
|
+
autoload :Client, File.expand_path("k8s_rails/client", __dir__)
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Settings, set once via `configure` (§5.1).
|
|
20
|
+
def config
|
|
21
|
+
@config ||= Configuration.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Configure the gem. Runs once — a second call warns and is ignored
|
|
25
|
+
# (design §5.1). Yields the Configuration object.
|
|
26
|
+
def configure
|
|
27
|
+
if @configured
|
|
28
|
+
warn "[K8sRails] K8sRails.configure called more than once; ignoring the second call."
|
|
29
|
+
return config
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@configured = true
|
|
33
|
+
yield config
|
|
34
|
+
config
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Reset configuration, the cached transport, and declared CRDs.
|
|
38
|
+
# Test support (§5.1).
|
|
39
|
+
def reset!
|
|
40
|
+
@config = nil
|
|
41
|
+
@configured = false
|
|
42
|
+
CRD.clear!
|
|
43
|
+
Client.reset! if client_loaded?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The shared transport (design §5.2). Triggers the lazy load of the
|
|
47
|
+
# kruby-dependent Client on first call.
|
|
48
|
+
def client
|
|
49
|
+
Client.build
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Lightweight connectivity check (§5.2). Raises Unavailable/ApiError.
|
|
53
|
+
def connected?
|
|
54
|
+
Client.connected?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Declare a CRD and return its Resource class (design §5.3, K5).
|
|
58
|
+
# Workflow = K8sRails.crd(group: "argoproj.io", version: "v1alpha1",
|
|
59
|
+
# plural: "workflows", kind: "Workflow")
|
|
60
|
+
# Re-declaring the same kind raises K8sRails::RedeclarationError.
|
|
61
|
+
def crd(group:, version:, plural:, kind:, namespace: nil, readonly: true)
|
|
62
|
+
CRD.declare(group:, version:, plural:, kind:, namespace:, readonly:)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Design §8: run an API call inside a `k8s-rails.request` notification.
|
|
66
|
+
#
|
|
67
|
+
# K8sRails.instrument(:list, group: "g", version: "v1", plural: "p", namespace: "ns") do
|
|
68
|
+
# # ... actual transport call ...
|
|
69
|
+
# end
|
|
70
|
+
#
|
|
71
|
+
# The notification payload is
|
|
72
|
+
# { operation:, group:, version:, plural:, namespace:, duration_ms:,
|
|
73
|
+
# status: "ok" | "unavailable" | "api_error" }.
|
|
74
|
+
# The block's return value always flows through unchanged. No-op when
|
|
75
|
+
# `config.instrumentation` is false or ActiveSupport is not loaded.
|
|
76
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- fixed §8 wrapper
|
|
77
|
+
def instrument(operation, metadata)
|
|
78
|
+
return yield unless instrumentation_enabled?
|
|
79
|
+
|
|
80
|
+
payload = { operation: operation }.merge(metadata)
|
|
81
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
|
+
begin
|
|
83
|
+
result = yield
|
|
84
|
+
payload[:status] = "ok"
|
|
85
|
+
rescue K8sRails::Unavailable
|
|
86
|
+
payload[:status] = "unavailable"
|
|
87
|
+
raise
|
|
88
|
+
rescue K8sRails::ApiError, K8sRails::NotFound
|
|
89
|
+
payload[:status] = "api_error"
|
|
90
|
+
raise
|
|
91
|
+
ensure
|
|
92
|
+
# Fallback for exceptions outside the K8sRails hierarchy (e.g. a
|
|
93
|
+
# programming error like NoMethodError from a malformed stub): keep
|
|
94
|
+
# the documented status enum (ok/unavailable/api_error) intact while
|
|
95
|
+
# re-raising the original exception.
|
|
96
|
+
payload[:status] ||= "api_error"
|
|
97
|
+
payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1_000).round(2)
|
|
98
|
+
ActiveSupport::Notifications.instrument("k8s-rails.request", payload)
|
|
99
|
+
end
|
|
100
|
+
result
|
|
101
|
+
end
|
|
102
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
103
|
+
|
|
104
|
+
# True only when the user opted in AND ActiveSupport is actually loaded
|
|
105
|
+
# (spec helper may stub this to exercise the no-op path deterministically).
|
|
106
|
+
def instrumentation_enabled?
|
|
107
|
+
config.instrumentation && defined?(ActiveSupport::Notifications)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Whether the kruby-dependent client file has actually been required yet
|
|
112
|
+
# (an autoloaded-but-unreferenced constant still counts as "not loaded"
|
|
113
|
+
# for `defined?`/`const_defined?`, so $LOADED_FEATURES is authoritative).
|
|
114
|
+
def self.client_loaded?
|
|
115
|
+
$LOADED_FEATURES.any? { |f| f.end_with?("k8s_rails/client.rb") }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Pure-Ruby components with no kruby dependency — safe to load eagerly.
|
|
120
|
+
require_relative "k8s_rails/version"
|
|
121
|
+
require_relative "k8s_rails/errors"
|
|
122
|
+
require_relative "k8s_rails/configuration"
|
|
123
|
+
require_relative "k8s_rails/normalizer"
|
|
124
|
+
require_relative "k8s_rails/crd"
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# kruby の require は本ファイルにのみ許される(design §7)。
|
|
4
|
+
# 他のファイルは kruby の定数・クラスを参照しない。
|
|
5
|
+
# Typhoeus は kruby が require するため転送層例外もここに閉じ込める。
|
|
6
|
+
require "kubernetes"
|
|
7
|
+
|
|
8
|
+
module K8sRails
|
|
9
|
+
# Resolves a connection to the Kubernetes API and exposes the four
|
|
10
|
+
# CustomObjects operations as a small internal transport (design §5.2 / §7).
|
|
11
|
+
#
|
|
12
|
+
# api = K8sRails::Client.build
|
|
13
|
+
# api.list(group, version, namespace, plural)
|
|
14
|
+
# api.get(group, version, namespace, plural, name)
|
|
15
|
+
# api.create(group, version, namespace, plural, body)
|
|
16
|
+
# api.patch(group, version, namespace, plural, name, body)
|
|
17
|
+
#
|
|
18
|
+
# Connection is LAZY: `build` does no network I/O — it only resolves a
|
|
19
|
+
# `Kubernetes::Configuration` and builds an in-memory `CustomObjectsApi`.
|
|
20
|
+
# The first real call is where DNS/timeout/TLS can fail.
|
|
21
|
+
class Client
|
|
22
|
+
class << self
|
|
23
|
+
# Return the shared transport, building it on first call (lazy connect).
|
|
24
|
+
#
|
|
25
|
+
# Resolution order (design §5.2):
|
|
26
|
+
# 0. `config.api_client` (test injection) → used as the transport
|
|
27
|
+
# directly, connection resolution skipped.
|
|
28
|
+
# 1. `config.connection` if set, else `Kubernetes::Configuration.default_config`
|
|
29
|
+
# 2. K1 bridge: duplicate `api_key['authorization']` into
|
|
30
|
+
# `api_key['BearerToken']` (kruby 1.36 in-cluster/KUBECONFIG write the
|
|
31
|
+
# token under 'authorization' but auth_settings reads 'BearerToken').
|
|
32
|
+
# 3. Build `ApiClient` → `CustomObjectsApi` (in-memory, no I/O).
|
|
33
|
+
# 4. Wrap in a StringKeyedAdapter that normalizes responses (K2) and
|
|
34
|
+
# converts kruby errors to K8sRails exceptions.
|
|
35
|
+
def build
|
|
36
|
+
@build ||=
|
|
37
|
+
if (injected = K8sRails.config.api_client)
|
|
38
|
+
StringKeyedAdapter.new(injected)
|
|
39
|
+
else
|
|
40
|
+
StringKeyedAdapter.new(build_custom_objects_api)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Lightweight connectivity probe (design §5.2). Performs one lightweight
|
|
45
|
+
# `/version` call via VersionApi and returns true on success. Raises
|
|
46
|
+
# K8sRails::Unavailable / ApiError on failure (the app may rescue).
|
|
47
|
+
def connected?
|
|
48
|
+
config = build_configuration
|
|
49
|
+
# The probe also authenticates — apply the K1 bridge or the Authorization
|
|
50
|
+
# header would be empty on clusters where /version requires auth.
|
|
51
|
+
bridge_bearer_token(config)
|
|
52
|
+
VersionApiProbe.new(config).probe
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Reset cached connection + transport (test support, §5.1).
|
|
56
|
+
def reset!
|
|
57
|
+
@build = nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Convert a kruby ApiError to the K8sRails exception tree (K3, §5.4).
|
|
61
|
+
# - code 0 (transport failure: DNS/timeout/connect/TLS) → Unavailable
|
|
62
|
+
# - code 404 → NotFound
|
|
63
|
+
# - anything else (401/403/409/422/5xx) → ApiError (keeps code + body)
|
|
64
|
+
def convert_api_error(kruby_error)
|
|
65
|
+
code = kruby_error.code
|
|
66
|
+
return Unavailable.new("K8s に接続できません: #{transport_message(kruby_error)}") if code.zero?
|
|
67
|
+
return NotFound.new("リソースが見つかりません") if code == 404
|
|
68
|
+
|
|
69
|
+
ApiError.new(
|
|
70
|
+
code: code,
|
|
71
|
+
response: kruby_error.response_body,
|
|
72
|
+
message: "Kubernetes API エラー (HTTP #{code})"
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# kruby's ApiError#message is "<msg>\nHTTP status code: N". For code 0 the
|
|
79
|
+
# message is the libcurl reason (e.g. "Could not resolve host"). Strip the
|
|
80
|
+
# status-code suffix for a clean Unavailable message.
|
|
81
|
+
def transport_message(kruby_error)
|
|
82
|
+
kruby_error.message.to_s.sub(/\nHTTP status code:.*\z/, "").strip
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Build a lazy in-memory CustomObjectsApi from the resolved configuration.
|
|
86
|
+
def build_custom_objects_api
|
|
87
|
+
config = build_configuration
|
|
88
|
+
bridge_bearer_token(config)
|
|
89
|
+
api_client = Kubernetes::ApiClient.new(config)
|
|
90
|
+
Kubernetes::CustomObjectsApi.new(api_client)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_configuration
|
|
94
|
+
K8sRails.config.connection || Kubernetes::Configuration.default_config
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# K1 bridge (kruby 1.36.x quirk): InClusterConfig writes the Bearer token
|
|
98
|
+
# to `api_key['authorization']` (and the KUBECONFIG path can too), but
|
|
99
|
+
# `Configuration#auth_settings` reads `api_key['BearerToken']` for the
|
|
100
|
+
# `Authorization` header. Without the bridge the header is empty → 401.
|
|
101
|
+
# Duplicate only when the target key is not already set.
|
|
102
|
+
def bridge_bearer_token(config)
|
|
103
|
+
auth = config.api_key["authorization"]
|
|
104
|
+
return if auth.nil? || config.api_key.key?("BearerToken")
|
|
105
|
+
|
|
106
|
+
config.api_key["BearerToken"] = auth
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Wraps a CustomObjectsApi (or an injected test double) so that
|
|
111
|
+
# - every response is deep-stringified (K2), and
|
|
112
|
+
# - kruby errors are converted to K8sRails exceptions (K3).
|
|
113
|
+
#
|
|
114
|
+
# The adapter is the ONLY place kruby response shapes / errors are touched,
|
|
115
|
+
# so a kruby upgrade is a one-file change (§7).
|
|
116
|
+
class StringKeyedAdapter
|
|
117
|
+
def initialize(transport)
|
|
118
|
+
@transport = transport
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def list(group, version, namespace, plural)
|
|
122
|
+
handle do
|
|
123
|
+
Normalizer.stringify(
|
|
124
|
+
@transport.list_namespaced_custom_object(group, version, namespace, plural)
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def get(group, version, namespace, plural, name)
|
|
130
|
+
handle do
|
|
131
|
+
Normalizer.stringify(
|
|
132
|
+
@transport.get_namespaced_custom_object(group, version, namespace, plural, name)
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def create(group, version, namespace, plural, body)
|
|
138
|
+
handle do
|
|
139
|
+
Normalizer.stringify(
|
|
140
|
+
@transport.create_namespaced_custom_object(group, version, namespace, plural, body)
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def patch(group, version, namespace, plural, name, body)
|
|
146
|
+
handle do
|
|
147
|
+
Normalizer.stringify(
|
|
148
|
+
@transport.patch_namespaced_custom_object(group, version, namespace, plural, name, body)
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def handle
|
|
156
|
+
yield
|
|
157
|
+
rescue Kubernetes::ApiError => e
|
|
158
|
+
raise Client.convert_api_error(e)
|
|
159
|
+
rescue Kubernetes::ConfigError, Typhoeus::Errors::TyphoeusError => e
|
|
160
|
+
raise Unavailable, "K8s に接続できません: #{e.message}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# One-shot /version probe backed by VersionApi#get_code (the kruby 1.36.x
|
|
165
|
+
# equivalent of a lightweight connectivity check).
|
|
166
|
+
class VersionApiProbe
|
|
167
|
+
def initialize(config)
|
|
168
|
+
@api = Kubernetes::VersionApi.new(Kubernetes::ApiClient.new(config))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def probe
|
|
172
|
+
@api.get_code
|
|
173
|
+
true
|
|
174
|
+
rescue Kubernetes::ApiError => e
|
|
175
|
+
raise Client.convert_api_error(e)
|
|
176
|
+
rescue Kubernetes::ConfigError, Typhoeus::Errors::TyphoeusError => e
|
|
177
|
+
raise Unavailable, "K8s に接続できません: #{e.message}"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K8sRails
|
|
4
|
+
# Settings held by `K8sRails.configure` (design §5.1).
|
|
5
|
+
#
|
|
6
|
+
# K8sRails.configure do |config|
|
|
7
|
+
# config.namespace = ENV.fetch("K8S_NAMESPACE", "default")
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# This class is pure data: it never touches the network or kruby.
|
|
11
|
+
class Configuration
|
|
12
|
+
# CRD 宣言が namespace 未指定時のデフォルト。
|
|
13
|
+
attr_accessor :namespace
|
|
14
|
+
|
|
15
|
+
# Kubernetes::Configuration インスタンス。省略時は default_config の自動
|
|
16
|
+
# 検出(in-cluster → KUBECONFIG)。認証を上書きする場合に指定する。
|
|
17
|
+
attr_accessor :connection
|
|
18
|
+
|
|
19
|
+
# テスト専用(§5.1)。CustomObjectsApi と同型の 4 メソッド
|
|
20
|
+
# (`get_namespaced_custom_object` 等の *_namespaced_custom_object 4 メソッド)を実装した素の
|
|
21
|
+
# オブジェクトを指定すると、Client.build は接続解決をスキープしてこれを
|
|
22
|
+
# 内部トランスポートとして使う。
|
|
23
|
+
attr_accessor :api_client
|
|
24
|
+
|
|
25
|
+
# ActiveSupport::Notifications での計測の ON/OFF(§8。M3 で実装)。
|
|
26
|
+
attr_accessor :instrumentation
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@namespace = "default"
|
|
30
|
+
@connection = nil
|
|
31
|
+
@api_client = nil
|
|
32
|
+
@instrumentation = true
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
|
|
5
|
+
module K8sRails
|
|
6
|
+
# CRD declaration registry + DSL (design §5.3 / K5).
|
|
7
|
+
#
|
|
8
|
+
# Workflow = K8sRails.crd(
|
|
9
|
+
# group: "argoproj.io", version: "v1alpha1",
|
|
10
|
+
# plural: "workflows", kind: "Workflow",
|
|
11
|
+
# namespace: K8sRails.config.namespace, # optional
|
|
12
|
+
# readonly: false, # default true
|
|
13
|
+
# )
|
|
14
|
+
#
|
|
15
|
+
# `crd` returns a `Resource` subclass with the coordinates bound; the same
|
|
16
|
+
# class is registered under its kind name so a second declaration of the
|
|
17
|
+
# same kind raises K8sRails::RedeclarationError (config-mistake detection).
|
|
18
|
+
# The registry is in-memory: no kruby, no network at declaration time.
|
|
19
|
+
class CRD
|
|
20
|
+
# Registered kinds, e.g. { "Workflow" => Workflow }.
|
|
21
|
+
def self.registered
|
|
22
|
+
@registered ||= {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.clear!
|
|
26
|
+
@registered = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Declare a CRD and return its Resource class (K5: group/version/plural/
|
|
30
|
+
# kind are explicit — never guessed). `readonly` must be an explicit
|
|
31
|
+
# boolean: mutations are enabled ONLY by `readonly: false` (design §5.3 /
|
|
32
|
+
# K4), so nil/other values fail fast instead of silently allowing writes.
|
|
33
|
+
def self.declare(group:, version:, plural:, kind:, namespace: nil, readonly: true)
|
|
34
|
+
unless [true, false].include?(readonly)
|
|
35
|
+
raise ArgumentError,
|
|
36
|
+
"readonly must be true or false (got #{readonly.inspect}) — mutations require an explicit readonly: false"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if registered.key?(kind)
|
|
40
|
+
raise RedeclarationError, "CRD kind #{kind} is already declared — re-declaration is a configuration mistake"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
resource = Resource.declare(
|
|
44
|
+
group: group,
|
|
45
|
+
version: version,
|
|
46
|
+
plural: plural,
|
|
47
|
+
kind: kind,
|
|
48
|
+
namespace: namespace,
|
|
49
|
+
readonly: readonly
|
|
50
|
+
)
|
|
51
|
+
registered[kind] = resource
|
|
52
|
+
resource
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K8sRails
|
|
4
|
+
# Base class for all K8sRails errors (K3, design §5.4).
|
|
5
|
+
#
|
|
6
|
+
# Apps rescue the specific subclass they care about, or `K8sRails::Error`
|
|
7
|
+
# to catch everything this gem raises. Errors that are NOT wrapped (e.g.
|
|
8
|
+
# programming errors like NoMethodError) propagate raw on purpose — we never
|
|
9
|
+
# mask a bug as a K8s problem.
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
# The cluster cannot be reached, or the connection/transport layer failed
|
|
13
|
+
# (DNS failure, timeout, connection refused, TLS handshake error, etc.).
|
|
14
|
+
#
|
|
15
|
+
# In kruby 1.36.x these surface as `Kubernetes::ApiError` with `code == 0`
|
|
16
|
+
# (the transport layer has no HTTP status). A small allowlist of low-level
|
|
17
|
+
# network exceptions is also mapped here in case a future kruby/Typhoeus
|
|
18
|
+
# release lets them escape raw (see Client::TRANSFER_LAYER_ERRORS).
|
|
19
|
+
class Unavailable < Error; end
|
|
20
|
+
|
|
21
|
+
# The requested resource does not exist (HTTP 404).
|
|
22
|
+
class NotFound < Error; end
|
|
23
|
+
|
|
24
|
+
# Any other Kubernetes API error (401/403/409/422/5xx, ...).
|
|
25
|
+
# Carries the HTTP `#code` and the raw API `#response` body so callers can
|
|
26
|
+
# inspect or display it.
|
|
27
|
+
class ApiError < Error
|
|
28
|
+
attr_reader :code, :response
|
|
29
|
+
|
|
30
|
+
def initialize(code:, response: nil, message: nil)
|
|
31
|
+
@code = code
|
|
32
|
+
@response = response
|
|
33
|
+
super(message || "Kubernetes API error (HTTP #{code})")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# create/patch was called on a CRD declared with `readonly: true` (M2).
|
|
38
|
+
# A configuration mistake — raised, never swallowed.
|
|
39
|
+
class ReadOnlyError < Error; end
|
|
40
|
+
|
|
41
|
+
# A CRD with the same name was declared twice (M2). A configuration mistake.
|
|
42
|
+
class RedeclarationError < Error; end
|
|
43
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K8sRails
|
|
4
|
+
# Pure-Ruby deep key stringification for API responses (K2, design §5.2).
|
|
5
|
+
#
|
|
6
|
+
# kruby deserializes JSON into Hash/Array with SYMBOL keys. Our public API
|
|
7
|
+
# contract (and the apps that consume it) expect STRING keys, so every
|
|
8
|
+
# response is passed through here before returning.
|
|
9
|
+
#
|
|
10
|
+
# This is deliberately NOT ActiveSupport's `deep_stringify_keys`: the gem
|
|
11
|
+
# must also work in non-Rails environments (cron scripts, etc.) where
|
|
12
|
+
# ActiveSupport is absent. No AS dependency.
|
|
13
|
+
module Normalizer
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Return a copy of +value+ with all Hash keys converted to strings.
|
|
17
|
+
# Non-Hash/Array values are returned unchanged (strings, numbers, nil,
|
|
18
|
+
# Time objects, ...).
|
|
19
|
+
def stringify(value)
|
|
20
|
+
deep_stringify_keys(value)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def deep_stringify_keys(value)
|
|
24
|
+
case value
|
|
25
|
+
when Hash
|
|
26
|
+
value.each_with_object({}) do |(key, entry), acc|
|
|
27
|
+
acc[key.to_s] = deep_stringify_keys(entry)
|
|
28
|
+
end
|
|
29
|
+
when Array
|
|
30
|
+
value.map { |entry| deep_stringify_keys(entry) }
|
|
31
|
+
else
|
|
32
|
+
value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K8sRails
|
|
4
|
+
# Base for generated CRD access classes (design §5.3).
|
|
5
|
+
#
|
|
6
|
+
# A `K8sRails.crd` declaration returns a `Class.new(Resource)` with the
|
|
7
|
+
# declared coordinates bound as class methods. All operations go through
|
|
8
|
+
# the shared transport (`K8sRails.client`), which handles kruby error
|
|
9
|
+
# conversion (K3) and response stringification (K2) — this class never
|
|
10
|
+
# touches kruby itself (§7).
|
|
11
|
+
#
|
|
12
|
+
# Workflow = K8sRails.crd(group: "argoproj.io", version: "v1alpha1",
|
|
13
|
+
# plural: "workflows", kind: "Workflow")
|
|
14
|
+
# Workflow.list
|
|
15
|
+
# Workflow.find("wf-1")
|
|
16
|
+
class Resource
|
|
17
|
+
# Bind declared coordinates to a fresh anonymous subclass. `namespace`
|
|
18
|
+
# may be nil — resolved from `K8sRails.config.namespace` at call time.
|
|
19
|
+
def self.declare(group:, version:, plural:, kind:, namespace: nil, readonly: true)
|
|
20
|
+
Class.new(self) do
|
|
21
|
+
define_singleton_method(:group_name) { group }
|
|
22
|
+
define_singleton_method(:version_name) { version }
|
|
23
|
+
define_singleton_method(:plural_name) { plural }
|
|
24
|
+
define_singleton_method(:kind_name) { kind }
|
|
25
|
+
define_singleton_method(:declared_namespace) { namespace }
|
|
26
|
+
define_singleton_method(:readonly?) { readonly }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# All objects in the namespace. Returns an array of string-keyed Hashes
|
|
32
|
+
# (design §5.3: `[{ "name" => "...", ... }]`).
|
|
33
|
+
def list(namespace: resolved_namespace)
|
|
34
|
+
K8sRails.instrument(:list, instrument_meta(namespace)) do
|
|
35
|
+
transport.list(group_name, version_name, namespace, plural_name)["items"] || []
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# One object by name. Raises K8sRails::NotFound when absent.
|
|
40
|
+
def find(name, namespace: resolved_namespace)
|
|
41
|
+
K8sRails.instrument(:find, instrument_meta(namespace)) do
|
|
42
|
+
transport.get(group_name, version_name, namespace, plural_name, name)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Like `find`, but returns nil instead of raising on NotFound.
|
|
47
|
+
def find_or_nil(name, namespace: resolved_namespace)
|
|
48
|
+
find(name, namespace: namespace)
|
|
49
|
+
rescue NotFound
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Create from a CRD body hash. `readonly: true` declarations raise
|
|
54
|
+
# K8sRails::ReadOnlyError (K4).
|
|
55
|
+
def create(attributes, namespace: resolved_namespace)
|
|
56
|
+
assert_writable
|
|
57
|
+
K8sRails.instrument(:create, instrument_meta(namespace)) do
|
|
58
|
+
transport.create(group_name, version_name, namespace, plural_name, attributes)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# JSON Patch a named object. Same readonly restriction as `create`.
|
|
63
|
+
def patch(name, operations, namespace: resolved_namespace)
|
|
64
|
+
assert_writable
|
|
65
|
+
K8sRails.instrument(:patch, instrument_meta(namespace)) do
|
|
66
|
+
transport.patch(group_name, version_name, namespace, plural_name, name, operations)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Notification metadata for design §8 (`k8s-rails.request`).
|
|
73
|
+
def instrument_meta(namespace)
|
|
74
|
+
{ group: group_name, version: version_name, plural: plural_name, namespace: }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Declared namespace wins; otherwise the gem default (resolved at call
|
|
78
|
+
# time so `K8sRails.reset!` + reconfigure works in tests).
|
|
79
|
+
def resolved_namespace
|
|
80
|
+
declared_namespace || K8sRails.config.namespace
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def transport
|
|
84
|
+
K8sRails.client
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def assert_writable
|
|
88
|
+
return unless readonly?
|
|
89
|
+
|
|
90
|
+
raise ReadOnlyError, "#{kind_name} is declared readonly — create/patch are disabled (K4)"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|