kube_cluster 0.1.1 → 0.2.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/Gemfile.lock ADDED
@@ -0,0 +1,108 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ kube_cluster (0.1.1)
5
+ kube_schema (~> 1.0)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ addressable (2.8.9)
11
+ public_suffix (>= 2.0.2, < 8.0)
12
+ ast (2.4.3)
13
+ bigdecimal (4.0.1)
14
+ black_hole_struct (0.1.3)
15
+ hana (1.3.7)
16
+ json (2.19.2)
17
+ json-schema (6.2.0)
18
+ addressable (~> 2.8)
19
+ bigdecimal (>= 3.1, < 5)
20
+ json_schemer (2.5.0)
21
+ bigdecimal
22
+ hana (~> 1.3)
23
+ regexp_parser (~> 2.0)
24
+ simpleidn (~> 0.2)
25
+ kube_schema (1.0.0)
26
+ black_hole_struct (~> 0.1)
27
+ json_schemer (~> 2.5)
28
+ rubyshell (~> 1.5)
29
+ language_server-protocol (3.17.0.5)
30
+ lint_roller (1.1.0)
31
+ mcp (0.8.0)
32
+ json-schema (>= 4.1)
33
+ minitest (5.27.0)
34
+ parallel (1.27.0)
35
+ parser (3.3.10.2)
36
+ ast (~> 2.4.1)
37
+ racc
38
+ prism (1.9.0)
39
+ public_suffix (7.0.5)
40
+ racc (1.8.1)
41
+ rainbow (3.1.1)
42
+ rake (13.3.1)
43
+ regexp_parser (2.11.3)
44
+ rubocop (1.85.1)
45
+ json (~> 2.3)
46
+ language_server-protocol (~> 3.17.0.2)
47
+ lint_roller (~> 1.1.0)
48
+ mcp (~> 0.6)
49
+ parallel (~> 1.10)
50
+ parser (>= 3.3.0.2)
51
+ rainbow (>= 2.2.2, < 4.0)
52
+ regexp_parser (>= 2.9.3, < 3.0)
53
+ rubocop-ast (>= 1.49.0, < 2.0)
54
+ ruby-progressbar (~> 1.7)
55
+ unicode-display_width (>= 2.4.0, < 4.0)
56
+ rubocop-ast (1.49.1)
57
+ parser (>= 3.3.7.2)
58
+ prism (~> 1.7)
59
+ ruby-progressbar (1.13.0)
60
+ rubyshell (1.5.0)
61
+ simpleidn (0.2.3)
62
+ unicode-display_width (3.2.0)
63
+ unicode-emoji (~> 4.1)
64
+ unicode-emoji (4.2.0)
65
+
66
+ PLATFORMS
67
+ ruby
68
+ x86_64-linux
69
+
70
+ DEPENDENCIES
71
+ kube_cluster!
72
+ minitest (~> 5.0)
73
+ rake (~> 13.0)
74
+ rubocop (~> 1.21)
75
+
76
+ CHECKSUMS
77
+ addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485
78
+ ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
79
+ bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
80
+ black_hole_struct (0.1.3) sha256=b1cac7dbe7f36bb3ed8372de656dbe140ad20d786aaace552c5706f7aa46c4a3
81
+ hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d
82
+ json (2.19.2) sha256=e7e1bd318b2c37c4ceee2444841c86539bc462e81f40d134cf97826cb14e83cf
83
+ json-schema (6.2.0) sha256=e8bff46ed845a22c1ab2bd0d7eccf831c01fe23bb3920caa4c74db4306813666
84
+ json_schemer (2.5.0) sha256=2f01fb4cce721a4e08dd068fc2030cffd0702a7f333f1ea2be6e8991f00ae396
85
+ kube_cluster (0.1.1)
86
+ kube_schema (1.0.0) sha256=a83e584b316f21492fe551231f22cf3e7439fd6f1df6f3769c24d66ab040dc6e
87
+ language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
88
+ lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
89
+ mcp (0.8.0) sha256=ae8bd146bb8e168852866fd26f805f52744f6326afb3211e073f78a95e0c34fb
90
+ minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
91
+ parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
92
+ parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357
93
+ prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
94
+ public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
95
+ racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
96
+ rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
97
+ rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
98
+ regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4
99
+ rubocop (1.85.1) sha256=3dbcf9e961baa4c376eeeb2a03913dca5e3987033b04d38fa538aa1e7406cc77
100
+ rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
101
+ ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
102
+ rubyshell (1.5.0) sha256=ffd528415962e52b2f3ec155fc3bc2cac401981413c0db451ea2a20194916ab6
103
+ simpleidn (0.2.3) sha256=08ce96f03fa1605286be22651ba0fc9c0b2d6272c9b27a260bc88be05b0d2c29
104
+ unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
105
+ unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
106
+
107
+ BUNDLED WITH
108
+ 4.0.7
data/TREE_PLAN.md ADDED
@@ -0,0 +1,513 @@
1
+ How will be now create ActiveRecord::Relatino style command objects?
2
+
3
+ 10:25
4
+
5
+ Architected chainable, lazy-evaluated kubectl command builder pattern
6
+ Architected chainable, lazy-evaluated kubectl command builder pattern
7
+ Great use case for the tree! The idea is an immutable, chainable builder that only shells out when forced — exactly like ActiveRecord::Relation.
8
+
9
+ ruby
10
+ class Kubectl
11
+ TREE = JSON.parse(
12
+ File.read(File.join(GEM_DIR, 'data', 'kubectl-command-tree-v1-minimal.json')),
13
+ symbolize_names: true
14
+ )
15
+
16
+ def initialize(commands: [], flags: {}, tree: TREE)
17
+ @commands = commands
18
+ @flags = flags
19
+ @tree = tree
20
+ end
21
+
22
+ def method_missing(name, *args)
23
+ cmd = name.to_s.tr('_', '-')
24
+ @tree.key?(cmd.to_sym).then do |valid|
25
+ raise ArgumentError, "Unknown kubectl command: #{cmd}" unless valid
26
+ self.class.new(
27
+ commands: @commands + [cmd] + args.map(&:to_s),
28
+ flags: @flags,
29
+ tree: @tree.dig(cmd.to_sym) || {}
30
+ )
31
+ end
32
+ end
33
+
34
+ def respond_to_missing?(name, include_private = false)
35
+ @tree.key?(name.to_s.tr('_', '-').to_sym) || super
36
+ end
37
+
38
+ def flag(key, value = nil)
39
+ self.class.new(
40
+ commands: @commands,
41
+ flags: @flags.merge(key.to_s.tr('_', '-') => value),
42
+ tree: @tree
43
+ )
44
+ end
45
+
46
+ def to_a
47
+ stdout, = Open3.capture2('kubectl', *@commands, *rendered_flags)
48
+ stdout.lines.map(&:chomp)
49
+ end
50
+
51
+ def to_s
52
+ ['kubectl', *@commands, *rendered_flags].join(' ')
53
+ end
54
+
55
+ private
56
+
57
+ def rendered_flags
58
+ @flags.flat_map do |k, v|
59
+ v ? ["--#{k}", v.to_s] : ["--#{k}"]
60
+ end
61
+ end
62
+ end
63
+ Which gives you a nice chainable API:
64
+
65
+ ruby
66
+ Kubectl.new.get('pods')
67
+ .flag(:namespace, 'production')
68
+ .flag(:output, 'json')
69
+ .to_a
70
+
71
+ Kubectl.new.rollout.status('deployment/my-app')
72
+ .flag(:namespace, 'staging')
73
+ .to_a
74
+
75
+ # Preview without running
76
+ Kubectl.new.get('nodes').flag(:output, 'wide').to_s
77
+ # => "kubectl get nodes --output wide"
78
+ Key design decisions:
79
+
80
+ Immutable — every chain returns a new instance with merged state, nothing mutates
81
+ method_missing walks the tree — invalid commands raise immediately rather than failing at shell time
82
+ to_a is the forcing function — lazy until you call it, just like ActiveRecord::Relation
83
+ to_s for free previewing — useful for debugging or logging the command before firing it
84
+ If Enumerable would respond to the method missing then make the api call..
85
+
86
+ 10:28
87
+
88
+ Architected automatic API invocation through Enumerable inclusion
89
+ Architected automatic API invocation through Enumerable inclusion
90
+ Include Enumerable and make each the single forcing function — then all enumerable methods trigger the shell call automatically:
91
+
92
+ ruby
93
+ include Enumerable
94
+
95
+ def each(&block)
96
+ to_a.each(&block)
97
+ end
98
+
99
+ def method_missing(name, *args, &block)
100
+ cmd = name.to_s.tr('_', '-')
101
+
102
+ if @tree.key?(cmd.to_sym)
103
+ self.class.new(
104
+ commands: @commands + [cmd] + args.map(&:to_s),
105
+ flags: @flags,
106
+ tree: @tree.dig(cmd.to_sym) || {}
107
+ )
108
+ elsif Enumerable.method_defined?(name)
109
+ to_a.public_send(name, *args, &block)
110
+ else
111
+ super
112
+ end
113
+ end
114
+ Now the API call happens naturally:
115
+
116
+ ruby
117
+ Kubectl.new.get('pods').flag(:namespace, 'production').map { |line| line.split.first }
118
+ Kubectl.new.get('nodes').flag(:output, 'wide').select { |line| line.include?('Ready') }
119
+ Kubectl.new.get('pods').first(3)
120
+ The chain stays lazy until an Enumerable method is hit — at which point each forces to_a which fires the shell command. No explicit .to_a needed.
121
+
122
+ Okay call this object CommandNode and create an additional object called ResourceNode which handles the deployment.v1.apps pattern... assume we have an array of all supported type.version.group sets.
123
+
124
+ 10:32
125
+
126
+ Architected nested tree structure for resource type parsing and integration
127
+ Architected nested tree structure for resource type parsing and integration
128
+ ruby
129
+ class ResourceNode
130
+ # e.g. ["deployments.v1.apps", "pods.v1", "replicasets.v1.apps", ...]
131
+ TREE = SUPPORTED_TYPES
132
+ .each_with_object({}) do |type_string, tree|
133
+ type_string
134
+ .split('.')
135
+ .reduce(tree) { |node, segment| node[segment.to_sym] ||= {} }
136
+ end
137
+
138
+ def initialize(path: [], tree: TREE)
139
+ @path = path
140
+ @tree = tree
141
+ end
142
+
143
+ def method_missing(name, *args, &block)
144
+ segment = name.to_s.tr('_', '-')
145
+
146
+ @tree.key?(segment.to_sym).then do |valid|
147
+ raise ArgumentError, "Unknown resource segment: #{segment}" unless valid
148
+ self.class.new(
149
+ path: @path + [segment],
150
+ tree: @tree[segment.to_sym]
151
+ )
152
+ end
153
+ end
154
+
155
+ def respond_to_missing?(name, include_private = false)
156
+ @tree.key?(name.to_s.tr('_', '-').to_sym) || super
157
+ end
158
+
159
+ def to_s
160
+ @path.join('.')
161
+ end
162
+ end
163
+ Which then composes naturally with CommandNode:
164
+
165
+ ruby
166
+ resource = ResourceNode.new.deployments.v1.apps
167
+
168
+ CommandNode.new
169
+ .get(resource.to_s)
170
+ .flag(:namespace, 'production')
171
+ .map { |line| line.split.first }
172
+ Or you could make CommandNode accept ResourceNode directly by checking in rendered_args:
173
+
174
+ ruby
175
+ # in CommandNode
176
+ def method_missing(name, *args, &block)
177
+ cmd = name.to_s.tr('_', '-')
178
+
179
+ if @tree.key?(cmd.to_sym)
180
+ self.class.new(
181
+ commands: @commands + [cmd] + args.map(&:to_s), # to_s coerces ResourceNode naturally
182
+ flags: @flags,
183
+ tree: @tree.dig(cmd.to_sym) || {}
184
+ )
185
+ elsif Enumerable.method_defined?(name)
186
+ to_a.public_send(name, *args, &block)
187
+ else
188
+ super
189
+ end
190
+ end
191
+ So the full idiomatic API becomes:
192
+
193
+ ruby
194
+ CommandNode.new
195
+ .get(ResourceNode.new.deployments.v1.apps)
196
+ .flag(:namespace, 'production')
197
+ .select { |line| line.include?('my-app') }
198
+ The ResourceNode tree is built once at load time from the flat array — segments become nested keys, so method_missing can walk it exactly like CommandNode walks the kubectl command tree.
199
+
200
+ hmm... this won't work... we need to be able to do Kubectl.get.deployment.v1 etc...
201
+
202
+ What we need to do it iterate over the schema and create some TreeInstances for each key... so each key turns from a string into a TreeInstance.... then when we're going throuhg our commands we know what the next node should be....
203
+
204
+ this is not a simple task... there are a lot of moving parts...
205
+
206
+ **make a plan** to get this right
207
+
208
+ 10:36
209
+
210
+ Architected tree-node system grafting commands and resources together
211
+ Architected tree-node system grafting commands and resources together
212
+ The Plan
213
+ The core insight: every step in the chain — whether a kubectl command or a resource segment — is the same thing: a node that knows what its valid children are. So we need one universal building block.
214
+
215
+ 1. TreeNode — the universal building block
216
+ TreeNode
217
+ name: string
218
+ children: { name => TreeNode }
219
+ Built at load time. Every key in every tree becomes a TreeNode instance pointing to its children. No method_missing string parsing at runtime — the graph is pre-resolved.
220
+
221
+ 2. Build two trees into TreeNode graphs
222
+ Command tree — from the JSON:
223
+
224
+ get -> TreeNode(name: "get", children: {})
225
+ rollout -> TreeNode(name: "rollout", children: {
226
+ status -> TreeNode(...),
227
+ history -> TreeNode(...)
228
+ })
229
+ Resource tree — from the flat array:
230
+
231
+ ["deployments.v1.apps", "pods.v1", ...]
232
+ -> deployments -> TreeNode(name: "deployments", children: {
233
+ v1 -> TreeNode(name: "v1", children: {
234
+ apps -> TreeNode(name: "apps", children: {})
235
+ })
236
+ })
237
+ 3. Graft the resource tree onto resource-accepting commands
238
+ Commands like get, delete, describe, edit accept a resource as their next argument. Their children should include the resource tree nodes:
239
+
240
+ get.children = resource_tree.children # deployments, pods, nodes...
241
+ This is the key step — it's what lets Kubectl.get.deployments.v1.apps resolve correctly through a single unified method_missing walk.
242
+
243
+ 4. CommandContext — the chainable, lazy evaluator
244
+ CommandContext
245
+ current_node: TreeNode # where we are in the graph
246
+ args: [] # accumulated path ["get", "deployments.v1.apps"]
247
+ flags: {} # --namespace production etc
248
+ method_missing — looks up name in current_node.children, returns new CommandContext with updated node + args
249
+ flag(key, value) — returns new CommandContext with merged flags
250
+ include Enumerable + each — forces the shell call, the single lazy boundary
251
+ to_s — renders the command without running it
252
+ 5. Entry point
253
+ ruby
254
+ module Kubectl
255
+ def self.method_missing(name, ...)
256
+ CommandContext.new(current_node: ROOT_COMMAND_NODE, args: [], flags: {})
257
+ .public_send(name, ...)
258
+ end
259
+ end
260
+ Load-time sequence
261
+ 1. Parse kubectl-command-tree-v1-minimal.json -> build command TreeNodes
262
+ 2. Parse SUPPORTED_TYPES array -> build resource TreeNodes
263
+ 3. Graft resource tree onto resource commands -> stitch the two graphs
264
+ 4. Assign root -> ROOT_COMMAND_NODE
265
+ Everything after that is just graph traversal. Sound right before we build it?
266
+
267
+ Not quite... the tree node has a type... deployment.v1.app is not 3 tree nodes...
268
+
269
+ 10:39
270
+
271
+ Reconsidered tree structure distinguishing resource nodes from command nodes
272
+ Reconsidered tree structure distinguishing resource nodes from command nodes
273
+ You're right. deployments.v1.apps is one thing — a resource type. The dots are qualifiers on that resource, not tree levels. So:
274
+
275
+ Revised Plan
276
+ TreeNode has a type:
277
+
278
+ TreeNode
279
+ name: string
280
+ type: :command | :resource
281
+ children: { name => TreeNode }
282
+ The resource tree is flat — one node per resource name:
283
+
284
+ deployments -> TreeNode(name: "deployments", type: :resource)
285
+ pods -> TreeNode(name: "pods", type: :resource)
286
+ Each ResourceNode holds all its possible version.group qualifiers internally:
287
+
288
+ deployments knows about: ["v1.apps", "v1"]
289
+ pods knows about: ["v1"]
290
+ So .v1.apps is qualification within the resource node, not tree traversal. The ResourceNode accumulates those segments until it resolves to a known type string.
291
+
292
+ The chain then looks like:
293
+
294
+ Kubectl.get # CommandNode -> type: :command
295
+ .deployments # ResourceNode -> type: :resource, resolved: false
296
+ .v1 # still ResourceNode, qualifier accumulated
297
+ .apps # ResourceNode -> resolved: "deployments.v1.apps"
298
+ .flag(...) # back to CommandContext
299
+ .select { ... } # forces shell call
300
+ Does that capture what you're thinking?
301
+
302
+ the Resource nodes don't know anything about any other resource nodes... they only know the command nodes that will come in future... that's how they know they've exited teh resoruce node phase...
303
+
304
+ what the Kubectl object shuold be ding here at everystep is building a hash datastructure...
305
+ which we end up passing like this...
306
+
307
+ def to_a
308
+ QueryBuilder.new(self.command_data).query.to_a
309
+ end
310
+ This means that the primary focus of each stage on method missing is to iterate over the options and return the appropriate node.
311
+
312
+ Show more
313
+ 10:42
314
+
315
+ Reconciled node architecture with hash-building data flow pattern
316
+ Reconciled node architecture with hash-building data flow pattern
317
+ Ah, now it's much clearer. Each node's only job is:
318
+
319
+ "given what I know about my valid next steps, what node do I hand back?"
320
+
321
+ And the whole chain is just accumulating a hash.
322
+
323
+ Revised Plan
324
+ The data structure being built:
325
+
326
+ ruby
327
+ {
328
+ commands: ["get"],
329
+ resource: "deployments.v1.apps",
330
+ flags: { namespace: "production" }
331
+ }
332
+ CommandNode — knows its valid subcommands AND the resource names:
333
+
334
+ method_missing:
335
+ name in subcommands? -> return CommandNode (append to commands)
336
+ name in resources? -> return ResourceNode (start accumulating resource)
337
+ Enumerable? -> force shell call
338
+ ResourceNode — knows the supported type strings AND the command nodes:
339
+
340
+ method_missing:
341
+ current_accumulation + segment completes a known type? -> stay ResourceNode
342
+ name in command nodes? -> exit, return CommandNode
343
+ Enumerable? -> force shell call
344
+ The ResourceNode doesn't know other resources — it only knows commands, which is exactly how it detects it's done.
345
+
346
+ Entry/exit at every step is just:
347
+
348
+ ruby
349
+ def method_missing(name, ...)
350
+ next_node(name).new(command_data.merge(...))
351
+ end
352
+ to_a at any point:
353
+
354
+ ruby
355
+ def to_a
356
+ QueryBuilder.new(command_data).query.to_a
357
+ end
358
+ Shall we build it?
359
+
360
+ yes
361
+
362
+ 10:43
363
+
364
+ Architected Ruby DSL for kubectl command chaining
365
+ Architected Ruby DSL for kubectl command chaining
366
+ ruby
367
+ require 'json'
368
+ require 'open3'
369
+
370
+ GEM_DIR = File.expand_path('..', __dir__)
371
+
372
+ COMMAND_TREE = JSON.parse(
373
+ File.read(File.join(GEM_DIR, 'data', 'kubectl-command-tree-v1-minimal.json')),
374
+ symbolize_names: true
375
+ )
376
+
377
+ SUPPORTED_TYPES = %w[
378
+ deployments.v1.apps
379
+ pods.v1
380
+ replicasets.v1.apps
381
+ ].freeze
382
+
383
+ RESOURCE_NAMES = SUPPORTED_TYPES.map { |t| t.split('.').first }.uniq.freeze
384
+
385
+ class QueryBuilder
386
+ def initialize(command_data)
387
+ @commands = command_data[:commands]
388
+ @resource = command_data[:resource]
389
+ @flags = command_data[:flags]
390
+ end
391
+
392
+ def query
393
+ stdout, = Open3.capture2('kubectl', *@commands, *[@resource].compact, *rendered_flags)
394
+ stdout.lines.map(&:chomp)
395
+ end
396
+
397
+ private
398
+
399
+ def rendered_flags
400
+ @flags.flat_map { |k, v| v ? ["--#{k}", v.to_s] : ["--#{k}"] }
401
+ end
402
+ end
403
+
404
+ class ResourceNode
405
+ include Enumerable
406
+
407
+ COMMAND_NAMES = COMMAND_TREE.keys.map(&:to_s).freeze
408
+
409
+ def initialize(command_data:, accumulated:)
410
+ @command_data = command_data
411
+ @accumulated = accumulated
412
+ end
413
+
414
+ def flag(key, value = nil)
415
+ self.class.new(
416
+ command_data: @command_data.merge(flags: @command_data[:flags].merge(key => value)),
417
+ accumulated: @accumulated
418
+ )
419
+ end
420
+
421
+ def each(&block)
422
+ QueryBuilder.new(@command_data.merge(resource: @accumulated)).query.each(&block)
423
+ end
424
+
425
+ def method_missing(name, *args, &block)
426
+ segment = name.to_s.tr('_', '-')
427
+ candidate = [@accumulated, segment].join('.')
428
+
429
+ if COMMAND_NAMES.include?(segment)
430
+ CommandNode.new(
431
+ command_data: @command_data.merge(resource: @accumulated, commands: @command_data[:commands] + [segment]),
432
+ tree: COMMAND_TREE[name]
433
+ )
434
+ elsif SUPPORTED_TYPES.any? { |t| t == candidate || t.start_with?("#{candidate}.") }
435
+ self.class.new(command_data: @command_data, accumulated: candidate)
436
+ else
437
+ super
438
+ end
439
+ end
440
+
441
+ def respond_to_missing?(name, include_private = false)
442
+ segment = name.to_s.tr('_', '-')
443
+ candidate = [@accumulated, segment].join('.')
444
+
445
+ COMMAND_NAMES.include?(segment) ||
446
+ SUPPORTED_TYPES.any? { |t| t == candidate || t.start_with?("#{candidate}.") } ||
447
+ super
448
+ end
449
+ end
450
+
451
+ class CommandNode
452
+ include Enumerable
453
+
454
+ def initialize(command_data: { commands: [], resource: nil, flags: {} }, tree: COMMAND_TREE)
455
+ @command_data = command_data
456
+ @tree = tree
457
+ end
458
+
459
+ def flag(key, value = nil)
460
+ self.class.new(
461
+ command_data: @command_data.merge(flags: @command_data[:flags].merge(key => value)),
462
+ tree: @tree
463
+ )
464
+ end
465
+
466
+ def each(&block)
467
+ QueryBuilder.new(@command_data).query.each(&block)
468
+ end
469
+
470
+ def method_missing(name, *args, &block)
471
+ segment = name.to_s.tr('_', '-')
472
+
473
+ if @tree.key?(name)
474
+ self.class.new(
475
+ command_data: @command_data.merge(commands: @command_data[:commands] + [segment]),
476
+ tree: @tree[name]
477
+ )
478
+ elsif RESOURCE_NAMES.include?(segment)
479
+ ResourceNode.new(command_data: @command_data, accumulated: segment)
480
+ else
481
+ super
482
+ end
483
+ end
484
+
485
+ def respond_to_missing?(name, include_private = false)
486
+ segment = name.to_s.tr('_', '-')
487
+ @tree.key?(name) || RESOURCE_NAMES.include?(segment) || super
488
+ end
489
+ end
490
+
491
+ module Kubectl
492
+ def self.method_missing(name, *args, &block)
493
+ CommandNode.new.public_send(name, *args, &block)
494
+ end
495
+
496
+ def self.respond_to_missing?(name, include_private = false)
497
+ CommandNode.new.respond_to?(name) || super
498
+ end
499
+ end
500
+ The chain then flows like:
501
+
502
+ ruby
503
+ Kubectl.get.deployments.v1.apps.flag(:namespace, 'production').select { |l| l.include?('my-app') }
504
+ # ^ ^ ^^^ ResourceNode accumulating
505
+ # | | until Enumerable forces QueryBuilder
506
+ # | RESOURCE_NAMES detected -> ResourceNode
507
+ # CommandNode
508
+ Key things to note:
509
+
510
+ ResourceNode only exits back to CommandNode if it sees a known command name — that's the only thing it knows about the outside world
511
+ Both nodes accumulate into command_data hash, which is the only thing QueryBuilder ever sees
512
+ each is the single forcing function on both nodes — Enumerable does the rest for free
513
+
data/bin/console CHANGED
@@ -2,7 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "bundler/setup"
5
- require "kube_cluster"
5
+ require "kube/cluster"
6
6
  require "irb"
7
7
 
8
8
  IRB.start(__FILE__)
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'json'
4
+ require 'open3'
5
+
6
+ def extract(args = [])
7
+ stdout, = Open3.capture2('kubectl', *args, '--help')
8
+ lines = stdout.lines
9
+
10
+ subcommands = lines
11
+ .lazy
12
+ .select { |l| l.match?(/^ \w[\w-]+\s{2,}/) }
13
+ .map { |l| l.strip.split(/\s{2,}/).first }
14
+ .reject { |cmd| cmd == 'help' }
15
+ .map { |cmd| [cmd, extract(args + [cmd])] }
16
+ .to_h
17
+
18
+ flags = lines
19
+ .lazy
20
+ .select { |l| l.match?(/^\s+--?[\w]/) }
21
+ .map { |l| l.strip }
22
+ .to_a
23
+
24
+ { flags:, subcommands: }
25
+ end
26
+
27
+ def minimize(tree)
28
+ tree[:subcommands]
29
+ .transform_values { |v| minimize(v) }
30
+ end
31
+
32
+ GEM_DIR = File.expand_path('..', __dir__)
33
+
34
+ extract.then do |tree|
35
+ File.write(
36
+ File.join(GEM_DIR, "data", "kubectl-command-tree-v1.json"),
37
+ JSON.pretty_generate(tree)
38
+ )
39
+
40
+ File.write(
41
+ File.join(GEM_DIR, "data", "kubectl-command-tree-v1-minimal.json"),
42
+ JSON.pretty_generate(minimize(tree))
43
+ )
44
+ end
@@ -2,7 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "erb"
5
- require_relative "../lib/kube_cluster/version"
5
+ require_relative "../lib/kube/cluster/version"
6
6
 
7
7
  USAGE = <<~TEXT
8
8
  Usage: bin/increment-version <major|minor|patch>
@@ -15,7 +15,7 @@ unless %w[major minor patch].include?(segment)
15
15
  exit 1
16
16
  end
17
17
 
18
- current = KubeCluster::VERSION
18
+ current = Kube::Cluster::VERSION
19
19
  major, minor, patch = current.split(".").map(&:to_i)
20
20
 
21
21
  case segment
@@ -32,8 +32,8 @@ end
32
32
 
33
33
  version = "#{major}.#{minor}.#{patch}"
34
34
 
35
- template_path = File.expand_path("../lib/kube_cluster/version.rb.erb", __dir__)
36
- output_path = File.expand_path("../lib/kube_cluster/version.rb", __dir__)
35
+ template_path = File.expand_path("../lib/kube/cluster/version.rb.erb", __dir__)
36
+ output_path = File.expand_path("../lib/kube/cluster/version.rb", __dir__)
37
37
 
38
38
  template = ERB.new(File.read(template_path))
39
39
  result = template.result(binding)
@@ -41,4 +41,3 @@ result = template.result(binding)
41
41
  File.write(output_path, result)
42
42
 
43
43
  puts "#{current} -> #{version}"
44
-