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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/bin/copy-schemas-over +1 -0
- data/examples/custom_crds.rb +117 -0
- data/examples/vcluster.rb +659 -0
- data/lib/kube/schema/instance.rb +90 -22
- data/lib/kube/schema/manifest.rb +36 -2
- data/lib/kube/schema/resource.rb +36 -14
- data/lib/kube/schema/version.rb +1 -1
- data/lib/kube/schema.rb +85 -3
- data/schemas/loft-definitions.json +14010 -0
- metadata +4 -2
- data/AGENTS.md +0 -1
data/lib/kube/schema/instance.rb
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
if
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
data/lib/kube/schema/manifest.rb
CHANGED
|
@@ -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|
|
|
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.
|
data/lib/kube/schema/resource.rb
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
data/lib/kube/schema/version.rb
CHANGED
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
|
-
#
|
|
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
|
|
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.
|