kube_schema 1.12.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.
- checksums.yaml +4 -4
- data/.envrc +1 -0
- data/.github/FUNDING.yml +3 -0
- data/.rubocop.yml +226 -5984
- data/CHANGELOG.md +110 -3
- data/DEPRECATIONS.md +172 -0
- data/Gemfile +13 -3
- data/Gemfile.lock +41 -31
- data/RELEASE.md +161 -0
- data/Rakefile +1 -1
- data/bin/rubocop +3 -0
- data/bin/test +2 -9
- data/cops/begin_block_assignment.rb +34 -0
- data/cops/comment_structure.rb +307 -0
- data/cops/constant_matches_path.rb +142 -0
- data/cops/multiline_method_arguments.rb +89 -0
- data/cops/no_conditional_assignment.rb +186 -0
- data/cops/no_console_logging.rb +62 -0
- data/cops/no_modifier_if.rb +74 -0
- data/cops/no_return.rb +152 -0
- data/cops/support/prism_source.rb +234 -0
- data/cops/use_tap.rb +100 -0
- data/flake.lock +3 -3
- data/flake.nix +22 -15
- data/gemset.nix +142 -73
- data/kube_schema.gemspec +4 -2
- data/lib/kube/errors.rb +652 -645
- data/lib/kube/monkey_patches.rb +5 -6
- data/lib/kube/schema/instance.rb +344 -363
- data/lib/kube/schema/lazy.rb +370 -0
- data/lib/kube/schema/manifest.rb +286 -325
- data/lib/kube/schema/resource.rb +348 -393
- data/lib/kube/schema/sub_spec.rb +261 -293
- data/lib/kube/schema.rb +173 -172
- data/lib/{kube/schema → kube_schema}/version.rb +1 -1
- data/lib/kube_schema.rb +6 -0
- data/readme.md +5 -0
- metadata +49 -9
- data/bin/increment-version +0 -45
- data/bin/release-gem +0 -32
- data/bin/tag-version +0 -19
- data/lib/kube/schema/version.rb.erb +0 -7
|
@@ -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
|