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.
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K8sRails
4
+ VERSION = "0.1.0"
5
+ end