kube_schema 1.3.1 → 1.3.4

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.
@@ -39,44 +39,47 @@ module Kube
39
39
 
40
40
  # Look up a resource by kind (e.g. "Deployment", "NetworkPolicy").
41
41
  # Returns a class that inherits from Kube::Schema::Resource.
42
+ #
43
+ # Custom schemas registered via Kube::Schema.register take precedence
44
+ # over built-in definitions, allowing users to override or extend the
45
+ # schema for any kind.
42
46
  def [](kind)
43
47
  @resource_classes[kind] ||= begin
44
- entry = find_gvk_entry(kind)
45
-
46
- if entry.nil?
47
- raise "No resource schema found for #{kind}!" \
48
- "\nUse #list_resources to see available kinds for v#{version}."
49
- end
50
-
51
- ref_schema = schemer.ref("#/definitions/#{entry[:definition_key]}")
52
- defaults = entry[:defaults].freeze
53
-
54
- Class.new(::Kube::Schema::Resource) do
55
- @schema = ref_schema
56
- @defaults = defaults
57
-
58
- def self.schema
59
- @schema || superclass.schema
48
+ # Custom schemas win over built-in definitions.
49
+ custom = find_custom_entry(kind)
50
+ if custom
51
+ build_resource_class(custom[:schema], custom[:defaults])
52
+ else
53
+ entry = find_gvk_entry(kind)
54
+
55
+ if entry.nil?
56
+ raise "No resource schema found for #{kind}!" \
57
+ "\nUse #list_resources to see available kinds for v#{version}."
60
58
  end
61
59
 
62
- def self.defaults
63
- @defaults || superclass.defaults
64
- end
60
+ ref_schema = schemer.ref("#/definitions/#{entry[:definition_key]}")
61
+ build_resource_class(ref_schema, entry[:defaults].freeze)
65
62
  end
66
63
  end
67
64
  end
68
65
 
69
- # All available resource kinds for this version.
66
+ # All available resource kinds for this version, including any
67
+ # custom schemas registered via Kube::Schema.register.
70
68
  #
71
69
  # @return [Array<String>] sorted kind names
72
70
  def list_resources
73
- gvk_index.keys.sort
71
+ (gvk_index.keys + Schema.custom_schemas.keys).uniq.sort
74
72
  end
75
73
 
76
74
  private
77
75
 
78
76
  # The JSONSchemer instance for this version's Swagger document.
79
77
  # Cached at the class level so it's built once per version.
78
+ #
79
+ # After loading the base Swagger JSON, merges in any extra definition
80
+ # files found in the schemas directory (e.g. crd-definitions.json,
81
+ # loft-definitions.json). These files are flat JSON objects where keys
82
+ # are definition names and values are OpenAPI v2 schema objects.
80
83
  def schemer
81
84
  self.class.schemers[@version] ||= begin
82
85
  path = File.join(SCHEMAS_DIR, "v#{version}.json")
@@ -87,7 +90,26 @@ module Kube
87
90
  "\nUse `Kube::Schema.schema_versions` to get a list."
88
91
  end
89
92
 
90
- JSONSchemer.schema(JSON.parse(File.read(path)))
93
+ schema = JSON.parse(File.read(path))
94
+
95
+ # Merge extra definition files (*-definitions.json) into the
96
+ # base schema so CRD and aggregated-API types (e.g. loft,
97
+ # gateway-api) are available alongside built-in k8s types.
98
+ Dir.glob(File.join(SCHEMAS_DIR, "*-definitions.json")).each do |defs_path|
99
+ extra = JSON.parse(File.read(defs_path))
100
+ schema["definitions"] ||= {}
101
+ schema["definitions"].merge!(extra)
102
+ end
103
+
104
+ # Kubernetes OpenAPI v2 defines IntOrString as "type": "string"
105
+ # with "format": "int-or-string". This is a limitation of
106
+ # OpenAPI v2 which cannot express union types. Patch the
107
+ # definition so JSONSchemer accepts both integers and strings.
108
+ if (int_or_str = schema.dig("definitions", "io.k8s.apimachinery.pkg.util.intstr.IntOrString"))
109
+ int_or_str["type"] = ["string", "integer"]
110
+ end
111
+
112
+ JSONSchemer.schema(schema)
91
113
  end
92
114
  end
93
115
 
@@ -151,6 +173,52 @@ module Kube
151
173
 
152
174
  nil
153
175
  end
176
+
177
+ # Find a custom schema entry by kind (case-insensitive).
178
+ # Returns the { schema:, defaults: } hash or nil.
179
+ def find_custom_entry(kind)
180
+ registry = Schema.custom_schemas
181
+ return registry[kind] if registry.key?(kind)
182
+
183
+ registry.each do |k, v|
184
+ return v if k.downcase == kind.downcase
185
+ end
186
+
187
+ nil
188
+ end
189
+
190
+ # Build a Resource subclass from a JSONSchemer instance and defaults hash.
191
+ def build_resource_class(schema_instance, defaults)
192
+ Class.new(::Kube::Schema::Resource) do
193
+ @schema = schema_instance
194
+ @defaults = defaults
195
+ @schema_properties = @schema.value["properties"].keys.map(&:to_sym)
196
+
197
+ def self.schema
198
+ @schema || superclass.schema
199
+ end
200
+
201
+ def self.defaults
202
+ @defaults || superclass.defaults
203
+ end
204
+
205
+ def self.schema_properties
206
+ @schema_properties
207
+ end
208
+
209
+ schema_instance.value["properties"].keys.then do |properties|
210
+ properties.each do |prop|
211
+ define_method(prop.to_sym) { @data[prop.to_sym] }
212
+ end
213
+ end
214
+ end
215
+ end
216
+
217
+ # Called by Kube::Schema.register and reset_custom_schemas! to
218
+ # invalidate cached resource classes so new registrations take effect.
219
+ def clear_resource_cache!
220
+ @resource_classes.clear
221
+ end
154
222
  end
155
223
  end
156
224
  end
@@ -90,8 +90,32 @@ module Kube
90
90
  # File I/O
91
91
  # -------------------------------------------------------------------
92
92
 
93
+ # Parse a YAML string containing one or more Kubernetes resource
94
+ # documents and return a Manifest populated with typed Resource objects.
95
+ #
96
+ # Each document's "kind" is resolved via Kube::Schema.parse to
97
+ # produce the correct Resource subclass (e.g. Deployment, Service).
98
+ # Documents without a recognized "kind" fall back to a bare Resource.
99
+ #
100
+ # yaml = `helm template my-release bitnami/nginx`
101
+ # manifest = Kube::Schema::Manifest.parse(yaml)
102
+ # manifest.first.class #=> Kube::Schema::Resource (Deployment subclass)
103
+ #
104
+ # @param yaml_string [String] multi-document YAML
105
+ # @return [Manifest]
106
+ def self.parse(yaml_string)
107
+ docs = if YAML.respond_to?(:safe_load_stream)
108
+ YAML.safe_load_stream(yaml_string, permitted_classes: [Symbol])
109
+ else
110
+ YAML.load_stream(yaml_string)
111
+ end
112
+
113
+ resources = docs.compact.map { |doc| parse_doc(doc) }
114
+ new(*resources)
115
+ end
116
+
93
117
  # Read a YAML file containing one or more Kubernetes resource documents
94
- # and return a Manifest populated with Resource objects.
118
+ # and return a Manifest populated with typed Resource objects.
95
119
  #
96
120
  # manifest = Kube::Schema::Manifest.open("deploy.yaml")
97
121
  # manifest.count #=> 3
@@ -108,7 +132,7 @@ module Kube
108
132
  YAML.load_stream(contents)
109
133
  end
110
134
 
111
- resources = docs.compact.map { |doc| Resource.new(doc) }
135
+ resources = docs.compact.map { |doc| parse_doc(doc) }
112
136
  new(*resources, filename: path)
113
137
  end
114
138
 
@@ -127,6 +151,16 @@ module Kube
127
151
  path
128
152
  end
129
153
 
154
+ # Parse a single YAML document hash into a typed Resource.
155
+ #
156
+ # @param doc [Hash] a parsed YAML document
157
+ # @return [Resource]
158
+ # @raise [RuntimeError] if the kind is not recognized
159
+ def self.parse_doc(doc)
160
+ Kube::Schema.parse(doc)
161
+ end
162
+ private_class_method :parse_doc
163
+
130
164
  private
131
165
 
132
166
  # Deep-stringify keys for clean YAML output.
@@ -11,12 +11,16 @@ module Kube
11
11
  # Therefore, they are ONLY ever set from the self.defaults
12
12
  # property.
13
13
  deep_symbolize_keys(hash).then do |symbolized|
14
- config = defaults.merge({
15
- metadata: symbolized.delete(:metadata) || {},
16
- spec: symbolized.delete(:spec) || {},
17
- })
18
-
19
- @data = config
14
+ @data = defaults
15
+
16
+ # This is extracting "top-level" properties from the input hash
17
+ # such as [apiVersion, spec, metadata, roleRef, ...]
18
+ # We then ignore the rest of the attributes by design.
19
+ self.class.schema_properties.each do |property|
20
+ if symbolized.key?(property)
21
+ @data[property] = symbolized.delete(property)
22
+ end
23
+ end
20
24
  end
21
25
  end
22
26
 
@@ -25,17 +29,20 @@ module Kube
25
29
  end
26
30
  end
27
31
 
28
- def apiVersion = @data.apiVersion
29
- def kind = @data.kind
30
- def spec = @data.spec
31
- def metadata = @data.metadata
32
-
33
32
  # Gets overridden by the factory in Kube::Schema::Instance
34
- def self.schema = nil
33
+ def self.schema
34
+ raise "Kube::Schema::Resource should NOT be instanciated directly"
35
+ end
36
+
37
+ def self.schema_properties
38
+ raise "Kube::Schema::Resource should NOT be instanciated directly"
39
+ end
35
40
 
36
41
  # Gets overridden by the factory in Kube::Schema::Instance.
37
42
  # Returns a frozen Hash like { "apiVersion" => "apps/v1", "kind" => "Deployment" }
38
- def self.defaults = nil
43
+ def self.defaults
44
+ raise "Kube::Schema::Resource should NOT be instanciated directly"
45
+ end
39
46
 
40
47
  def valid?
41
48
  if self.class.schema.nil?
@@ -69,7 +76,8 @@ module Kube
69
76
  # they are facts derived from the GVK metadata.
70
77
  def to_h
71
78
  defaults = self.class.defaults
72
- data = @data.reject { |_, v| v.is_a?(Hash) && v.empty? }
79
+ data = deep_compact(@data)
80
+ data = data.reject { |_, v| v.is_a?(Hash) && v.empty? }
73
81
 
74
82
  if defaults
75
83
  symbolized = deep_symbolize_keys(defaults)
@@ -95,6 +103,20 @@ module Kube
95
103
 
96
104
  private
97
105
 
106
+ def deep_compact(obj)
107
+ case obj
108
+ when Hash
109
+ obj.each_with_object({}) do |(k, v), result|
110
+ compacted = deep_compact(v)
111
+ result[k] = compacted unless compacted.nil?
112
+ end
113
+ when Array
114
+ obj.map { |v| deep_compact(v) }
115
+ else
116
+ obj
117
+ end
118
+ end
119
+
98
120
  def deep_stringify_keys(obj)
99
121
  case obj
100
122
  when Hash
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Kube
4
4
  module Schema
5
- VERSION = "1.3.1"
5
+ VERSION = "1.3.4"
6
6
  end
7
7
  end
data/lib/kube/schema.rb CHANGED
@@ -16,6 +16,7 @@ module Kube
16
16
 
17
17
  @schema_version = nil
18
18
  @instances = {}
19
+ @custom_schemas = {}
19
20
 
20
21
  GEM_ROOT = File.expand_path("../..", __dir__).freeze
21
22
  SCHEMAS_DIR = File.join(GEM_ROOT, "schemas").freeze
@@ -26,6 +27,71 @@ module Kube
26
27
  # When nil, the DEFAULT_VERSION is used.
27
28
  attr_accessor :schema_version
28
29
 
30
+ # Custom schemas registered via Kube::Schema.register.
31
+ # Keys are kind strings, values are { schema:, defaults: } hashes.
32
+ attr_reader :custom_schemas
33
+
34
+ # Register a standalone JSON Schema for a custom resource kind.
35
+ #
36
+ # This lets users add CRD schemas from any source — for example,
37
+ # the datreeio/CRDs-catalog, operator repos, or their own CRDs.
38
+ # Registered kinds take precedence over built-in definitions.
39
+ #
40
+ # @param kind [String] The Kubernetes Kind (e.g. "Certificate")
41
+ # @param schema [Hash, String, Pathname] JSON Schema as a Hash,
42
+ # a JSON string, or a file path to a .json file
43
+ # @param api_version [String] The apiVersion (e.g. "cert-manager.io/v1")
44
+ #
45
+ # @example Register from a local file
46
+ # Kube::Schema.register("Certificate",
47
+ # schema: "schemas/cert-manager.io/certificate_v1.json",
48
+ # api_version: "cert-manager.io/v1"
49
+ # )
50
+ #
51
+ # @example Register from Chart#crds
52
+ # chart.crds.each do |crd|
53
+ # s = crd.to_json_schema
54
+ # Kube::Schema.register(s[:kind], schema: s[:schema], api_version: s[:api_version])
55
+ # end
56
+ #
57
+ def register(kind, schema:, api_version:)
58
+ require "json"
59
+ require "json_schemer"
60
+
61
+ parsed = case schema
62
+ when Hash
63
+ schema
64
+ when String, Pathname
65
+ path = schema.to_s
66
+ if File.exist?(path)
67
+ JSON.parse(File.read(path))
68
+ else
69
+ JSON.parse(path)
70
+ end
71
+ else
72
+ raise ArgumentError,
73
+ "schema must be a Hash, a JSON string, or a file path — got #{schema.class}"
74
+ end
75
+
76
+ @custom_schemas[kind] = {
77
+ schema: JSONSchemer.schema(parsed),
78
+ defaults: { "apiVersion" => api_version, "kind" => kind }.freeze
79
+ }
80
+
81
+ # Invalidate cached resource classes on all instances so the
82
+ # new registration takes effect immediately.
83
+ @instances.each_value { |inst| inst.send(:clear_resource_cache!) }
84
+
85
+ kind
86
+ end
87
+
88
+ # Remove all custom schema registrations.
89
+ # Useful for test teardown or resetting state.
90
+ def reset_custom_schemas!
91
+ @custom_schemas.clear
92
+ @instances.each_value { |inst| inst.send(:clear_resource_cache!) }
93
+ end
94
+
29
95
  # Kube::Schema["1.34"] => cached Instance (supports ["Deployment"] chaining)
30
96
  # Kube::Schema["Deployment"] => Resource via the default version
31
97
  def [](key)
@@ -51,10 +117,26 @@ module Kube
51
117
  end
52
118
  end
53
119
 
54
- # Build a Resource from a hash.
55
- # Kube::Schema.parse(Kube::Schema["Deployment"].to_h) == Kube::Schema["Deployment"]
120
+ # Build a typed Resource from a raw hash.
121
+ #
122
+ # Looks up the "kind" key in the hash and resolves it to the
123
+ # correct Resource subclass via the schema registry. The hash
124
+ # may use string or symbol keys.
125
+ #
126
+ # Kube::Schema.parse("kind" => "Deployment", "apiVersion" => "apps/v1")
127
+ # Kube::Schema.parse(kind: "Pod", apiVersion: "v1", metadata: { name: "web" })
128
+ #
129
+ # @param hash [Hash] a Kubernetes resource hash with at least a "kind" key
130
+ # @return [Resource] a schema-validated Resource instance
131
+ # @raise [ArgumentError] if the hash is nil, not a Hash, or missing "kind"
56
132
  def parse(hash)
57
- raise NotImplementedError
133
+ raise ArgumentError, "Expected a Hash, got #{hash.class}" unless hash.is_a?(Hash)
134
+
135
+ kind = hash["kind"] || hash[:kind]
136
+ raise ArgumentError, "Hash must contain a \"kind\" key" if kind.nil?
137
+
138
+ resource_class = self[kind]
139
+ resource_class.new(hash)
58
140
  end
59
141
 
60
142
  # Available Kubernetes versions, read from the local schemas directory.