kube_schema 1.11.0 → 2.0.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.
@@ -0,0 +1,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "json_schemer"
5
+ require "tempfile"
6
+ require "weakref"
7
+
8
+ module Kube
9
+ module Schema
10
+ module Lazy
11
+ REF_SCHEME = "kube"
12
+ REF_PREFIX = "#/definitions/"
13
+ INT_OR_STRING = "io.k8s.apimachinery.pkg.util.intstr.IntOrString"
14
+
15
+ module Scanner
16
+ module_function
17
+
18
+ def string_end(data, start)
19
+ i = start + 1
20
+
21
+ while (i = data.index('"', i))
22
+ backslashes = 0
23
+ j = i - 1
24
+
25
+ while data[j] == "\\"
26
+ backslashes += 1
27
+ j -= 1
28
+ end
29
+
30
+ if backslashes.even?
31
+ break i
32
+ end
33
+
34
+ i += 1
35
+ end || raise("unterminated string at #{start}")
36
+ end
37
+
38
+ def object_end(data, start)
39
+ depth = 0
40
+ i = start
41
+
42
+ while (i = data.index(/["{}]/, i))
43
+ case data[i]
44
+ when '"'
45
+ i = string_end(data, i) + 1
46
+ when "{"
47
+ depth += 1
48
+ i += 1
49
+ when "}"
50
+ depth -= 1
51
+ i += 1
52
+
53
+ if depth.zero?
54
+ break i
55
+ end
56
+ end
57
+ end || raise("unterminated object at #{start}")
58
+ end
59
+
60
+ def scan(path)
61
+ data = File.binread(path)
62
+ anchor = data.index('"definitions"')
63
+ pos = data.index("{", anchor ? anchor + 13 : 0) + 1
64
+ entries = {}
65
+
66
+ while (pos = data.index(/[^\s,]/, pos))
67
+ if data[pos] == "}"
68
+ break
69
+ end
70
+
71
+ unless data[pos] == '"'
72
+ raise "expected a definition key at #{pos} of #{path}"
73
+ end
74
+
75
+ key_end = string_end(data, pos)
76
+ key = data[(pos + 1)...key_end]
77
+ pos = data.index(/[^\s:]/, key_end + 1)
78
+ value_start = pos
79
+ pos = object_end(data, pos)
80
+
81
+ entries[key] = [path, value_start, pos - value_start]
82
+ end
83
+
84
+ entries
85
+ end
86
+ end
87
+
88
+ Entry = Struct.new(
89
+ :path,
90
+ :offset,
91
+ :length,
92
+ :gvks,
93
+ :properties,
94
+ :spilled,
95
+ )
96
+
97
+ module Spill
98
+ module_function
99
+
100
+ def files = @files ||= {}
101
+
102
+ def write(key, hash)
103
+ json = JSON.generate(hash)
104
+ file = Tempfile.new(["kube-schema-", ".json"])
105
+ file.binmode
106
+ file.write(json)
107
+ file.flush
108
+ files[key] = file
109
+
110
+ Entry.new(
111
+ file.path,
112
+ 0,
113
+ json.bytesize,
114
+ hash["x-kubernetes-group-version-kind"],
115
+ hash.fetch("properties", {}).keys,
116
+ true,
117
+ )
118
+ end
119
+
120
+ def clear
121
+ files.each_value(&:close!)
122
+ files.clear
123
+ end
124
+ end
125
+
126
+ class Index
127
+ def self.build(paths)
128
+ entries = {}
129
+
130
+ paths.each do |path|
131
+ Scanner.scan(path).each do |key, (file, offset, length)|
132
+ definition = JSON.parse(File.binread(file, length, offset))
133
+
134
+ entries[key] = Entry.new(
135
+ file,
136
+ offset,
137
+ length,
138
+ definition["x-kubernetes-group-version-kind"],
139
+ definition.fetch("properties", {}).keys,
140
+ )
141
+ end
142
+ end
143
+
144
+ new(entries)
145
+ end
146
+
147
+ def initialize(entries)
148
+ @entries = entries
149
+ end
150
+
151
+ attr_reader :entries
152
+
153
+ def keys = @entries.keys
154
+ def key?(key) = @entries.key?(key)
155
+ def [](key) = @entries[key]
156
+ def size = @entries.size
157
+ end
158
+
159
+ class Source
160
+ def self.indexed(index, key)
161
+ new(index, index[key] || raise("No definition #{key.inspect} in the index!"), key)
162
+ end
163
+
164
+ def self.spilled(index, entry) = new(index, entry)
165
+
166
+ def initialize(index, entry, key = nil)
167
+ @index = index
168
+ @entry = entry
169
+ @key = key
170
+ end
171
+
172
+ attr_reader :key, :entry
173
+
174
+ def value = Lazy.load(@entry, @key)
175
+
176
+ def valid?(data) = schema.valid?(data)
177
+ def validate(data) = schema.validate(data)
178
+ def ref(pointer) = schema.ref(pointer)
179
+
180
+ def schema
181
+ if @compiled&.weakref_alive?
182
+ live = @compiled.__getobj__
183
+ else
184
+ live = nil
185
+ end
186
+
187
+ live || begin
188
+ compiled = JSONSchemer.schema(Lazy.rewrite(value), ref_resolver: Lazy.resolver(@index))
189
+ @compiled = WeakRef.new(compiled)
190
+ compiled
191
+ end
192
+ end
193
+ end
194
+
195
+ module_function
196
+
197
+ def patch(key, definition)
198
+ if key == INT_OR_STRING
199
+ definition["type"] = ["string", "integer"]
200
+ end
201
+
202
+ definition
203
+ end
204
+
205
+ def rewrite(node)
206
+ case node
207
+ when Hash
208
+ node.each_with_object({}) do |(key, value), out|
209
+ if key == "$ref" && value.is_a?(String) && value.start_with?(REF_PREFIX)
210
+ out[key] = "#{REF_SCHEME}:///#{value.delete_prefix(REF_PREFIX)}"
211
+ else
212
+ out[key] = rewrite(value)
213
+ end
214
+ end
215
+ when Array
216
+ node.map { |item| rewrite(item) }
217
+ else
218
+ node
219
+ end
220
+ end
221
+
222
+ def load(entry, key = nil)
223
+ patch(key, JSON.parse(File.binread(entry.path, entry.length, entry.offset)))
224
+ rescue Errno::ENOENT
225
+ raise MissingSchemaError, missing_message(entry, key)
226
+ end
227
+
228
+ def missing_message(entry, key)
229
+ if key
230
+ named = "Schema #{key.inspect}"
231
+ else
232
+ named = "Schema"
233
+ end
234
+
235
+ if entry.spilled
236
+ "#{named} is missing: the temp file it was spilled to (#{entry.path}) is gone." \
237
+ "\nSomething garbage collected or swept it -- Tempfile unlinks on GC, and tmpdirs get cleaned." \
238
+ "\nRe-register the schema with Kube::Schema.register."
239
+ else
240
+ "#{named} is missing: #{entry.path} could not be read."
241
+ end
242
+ end
243
+
244
+ def raw(index, key)
245
+ entry = index[key] or raise "No definition #{key.inspect} in the index!"
246
+
247
+ load(entry, key)
248
+ end
249
+
250
+ def definition(index, key)
251
+ rewrite(raw(index, key))
252
+ end
253
+
254
+ def resolver(index)
255
+ ->(uri) { definition(index, uri.path.delete_prefix("/")) }
256
+ end
257
+
258
+ def schema_for(index, key)
259
+ JSONSchemer.schema(definition(index, key), ref_resolver: resolver(index))
260
+ end
261
+
262
+ def schema_from(hash, index)
263
+ JSONSchemer.schema(rewrite(hash), ref_resolver: resolver(index))
264
+ end
265
+ end
266
+ end
267
+ end
268
+
269
+ __END__
270
+
271
+ require "kube/schema"
272
+
273
+ schemas = File.expand_path("../../../schemas", __dir__)
274
+ paths = [File.join(schemas, "v1.33.json")] + Dir.glob(File.join(schemas, "*-definitions.json")).sort
275
+ index = Kube::Schema::Lazy::Index.build(paths)
276
+
277
+ deployment = lambda {
278
+ {
279
+ "apiVersion" => "apps/v1",
280
+ "kind" => "Deployment",
281
+ "metadata" => {"name" => "web"},
282
+ "spec" => {
283
+ "selector" => {"matchLabels" => {"app" => "web"}},
284
+ "template" => {"spec" => {"containers" => [{"name" => "web", "image" => "nginx"}]}}
285
+ }
286
+ }
287
+ }
288
+
289
+ describe "Kube::Schema::Lazy::Scanner" do
290
+ it "slices every definition byte-identically to a full parse" do
291
+ paths.each do |path|
292
+ parsed = JSON.parse(File.read(path))
293
+ truth = parsed.fetch("definitions", parsed)
294
+ scanned = Kube::Schema::Lazy::Scanner.scan(path)
295
+
296
+ scanned.keys.sort.should == truth.keys.sort
297
+
298
+ scanned.each do |key, (file, offset, length)|
299
+ JSON.parse(File.binread(file, length, offset)).should == truth[key]
300
+ end
301
+ end
302
+ end
303
+ end
304
+
305
+ describe "Kube::Schema::Lazy::Index" do
306
+ it "indexes every definition across the version and extra files" do
307
+ index.size.should.be > 1_000
308
+ index["io.k8s.api.apps.v1.Deployment"].gvks.first["kind"].should == "Deployment"
309
+ index["io.k8s.api.apps.v1.Deployment"].properties.should.include "spec"
310
+ index["io.k8s.api.apps.v1.Deployment"].properties.should.include "metadata"
311
+ end
312
+ end
313
+
314
+ describe "Kube::Schema::Lazy::Source" do
315
+ source = Kube::Schema::Lazy::Source.indexed(index, "io.k8s.api.apps.v1.Deployment")
316
+
317
+ it "exposes properties without compiling a schema" do
318
+ source.value["properties"].keys.should.include "spec"
319
+ end
320
+
321
+ it "validates through a compiled schema" do
322
+ source.valid?(deployment.()).should.be.true
323
+ end
324
+
325
+ it "reports errors that are only reachable through refs" do
326
+ manifest = deployment.()
327
+ manifest["spec"]["template"]["spec"]["containers"] = [{"image" => "nginx"}]
328
+
329
+ source.validate(manifest).to_a.size.should == 1
330
+ end
331
+ end
332
+
333
+ describe "Kube::Schema::Lazy.load" do
334
+ it "raises MissingSchemaError when a spilled temp file has vanished" do
335
+ entry = Kube::Schema::Lazy::Spill.write("v1/Thing", {"properties" => {"spec" => {}}})
336
+ File.unlink(entry.path)
337
+
338
+ error = lambda { Kube::Schema::Lazy.load(entry, "v1/Thing") }.should.raise(Kube::MissingSchemaError)
339
+ error.message.should.match(/temp file/)
340
+ end
341
+
342
+ it "raises MissingSchemaError when a shipped schema file cannot be read" do
343
+ entry = Kube::Schema::Lazy::Entry.new("/nonexistent/schema.json", 0, 10, nil, [])
344
+
345
+ error = lambda { Kube::Schema::Lazy.load(entry, "io.k8s.Thing") }.should.raise(Kube::MissingSchemaError)
346
+ error.message.should.match(/could not be read/)
347
+ end
348
+
349
+ it "surfaces the error through Source#valid?" do
350
+ entry = Kube::Schema::Lazy::Spill.write("v1/Gone", {"properties" => {}})
351
+ spilled = Kube::Schema::Lazy::Source.spilled(index, entry)
352
+ File.unlink(entry.path)
353
+
354
+ lambda { spilled.valid?({}) }.should.raise(Kube::MissingSchemaError)
355
+ end
356
+ end
357
+
358
+ describe "Kube::Schema::Lazy.patch" do
359
+ it "widens IntOrString to accept integers and strings" do
360
+ Kube::Schema::Lazy.raw(index, Kube::Schema::Lazy::INT_OR_STRING)["type"].should == ["string", "integer"]
361
+ end
362
+ end
363
+
364
+ describe "Kube::Schema::Lazy.schema_for" do
365
+ it "compiles only the subschemas the instance touches" do
366
+ Kube::Schema::Lazy.schema_for(index, "io.k8s.api.apps.v1.Deployment").valid?(deployment.())
367
+
368
+ ObjectSpace.each_object(JSONSchemer::Schema).count.should.be < 2_000
369
+ end
370
+ end