mt-wall 0.1.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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +55 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +166 -0
  5. data/docs/dsl-reference.md +388 -0
  6. data/docs/gitops.md +173 -0
  7. data/docs/security.md +142 -0
  8. data/examples/README.md +67 -0
  9. data/examples/config/devices/edge-1.rb +44 -0
  10. data/examples/config/devices/edge-2.rb +41 -0
  11. data/examples/config/objects.rb +46 -0
  12. data/examples/config/policy.rb +30 -0
  13. data/examples/config/services.rb +19 -0
  14. data/exe/mt-wall +6 -0
  15. data/lib/mt/wall/cli.rb +473 -0
  16. data/lib/mt/wall/compiler.rb +613 -0
  17. data/lib/mt/wall/configuration.rb +123 -0
  18. data/lib/mt/wall/desired_state.rb +200 -0
  19. data/lib/mt/wall/dsl/chain_builder.rb +112 -0
  20. data/lib/mt/wall/dsl/device_builder.rb +149 -0
  21. data/lib/mt/wall/dsl/group_builder.rb +36 -0
  22. data/lib/mt/wall/dsl/host_builder.rb +64 -0
  23. data/lib/mt/wall/dsl/nat_builder.rb +114 -0
  24. data/lib/mt/wall/dsl/policy_scope.rb +31 -0
  25. data/lib/mt/wall/dsl/root_builder.rb +141 -0
  26. data/lib/mt/wall/dsl/rule_builder.rb +86 -0
  27. data/lib/mt/wall/dsl/rule_scope.rb +35 -0
  28. data/lib/mt/wall/dsl/validators.rb +306 -0
  29. data/lib/mt/wall/dsl.rb +61 -0
  30. data/lib/mt/wall/errors.rb +19 -0
  31. data/lib/mt/wall/model/address_object.rb +35 -0
  32. data/lib/mt/wall/model/device.rb +54 -0
  33. data/lib/mt/wall/model/filter_rule.rb +66 -0
  34. data/lib/mt/wall/model/group.rb +27 -0
  35. data/lib/mt/wall/model/nat_rule.rb +49 -0
  36. data/lib/mt/wall/model/policy.rb +27 -0
  37. data/lib/mt/wall/model/rule.rb +50 -0
  38. data/lib/mt/wall/model/service.rb +42 -0
  39. data/lib/mt/wall/plan.rb +304 -0
  40. data/lib/mt/wall/reconciler.rb +148 -0
  41. data/lib/mt/wall/transport/base.rb +79 -0
  42. data/lib/mt/wall/transport/rest_api.rb +464 -0
  43. data/lib/mt/wall/transport/rsc.rb +99 -0
  44. data/lib/mt/wall/version.rb +7 -0
  45. data/lib/mt/wall.rb +56 -0
  46. metadata +91 -0
@@ -0,0 +1,473 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+
6
+ module Mt
7
+ module Wall
8
+ # Command-line entry point driving the GitOps loop:
9
+ #
10
+ # mt-wall validate <paths...> # load + compile the DSL, no device
11
+ # mt-wall plan <paths...> [--device] # show the diff per device (read-only)
12
+ # mt-wall apply <paths...> [--device] # converge each device to desired
13
+ #
14
+ # PATHS may be `*.rb` files and/or DIRECTORIES (a directory contributes every
15
+ # `*.rb` under it, recursively, in sorted order) — see Mt::Wall.load.
16
+ #
17
+ # FLEET: plan/apply operate on EVERY device in the loaded Configuration by
18
+ # default. `--device NAME` may be REPEATED to target a subset. Output is a
19
+ # per-device section followed by a rollup summary line.
20
+ #
21
+ # Typical CI wiring: `validate` + `plan` on every PR, `apply` on merge.
22
+ #
23
+ # EXIT CODES (CI-friendly, Terraform-style; aggregated across the fleet):
24
+ # 0 success / no device has changes
25
+ # 1 error (invalid DSL, unknown device, transport/plan failure, bad args,
26
+ # apply aborted/refused, any device failed during apply)
27
+ # 2 `plan` ONLY: success, but at least one device has pending changes
28
+ #
29
+ # SECRETS: credentials are NEVER passed on the command line. The per-device
30
+ # transport adapter (selected by the DSL `transport:`) reads them from ENV.
31
+ class CLI # rubocop:disable Metrics/ClassLength
32
+ EXIT_OK = 0
33
+ EXIT_ERROR = 1
34
+ # `plan` returns this when the diff is non-empty so CI can branch on "there
35
+ # is work to apply" without parsing stdout.
36
+ EXIT_CHANGES = 2
37
+
38
+ # Glyphs prefixing each operation in a printed plan (Terraform-style).
39
+ ACTION_GLYPHS = { create: "+", update: "~", delete: "-", move: "»" }.freeze
40
+
41
+ USAGE = <<~USAGE
42
+ Usage: mt-wall {validate|plan|apply} [options] <paths...>
43
+
44
+ validate <paths...> Load and compile the DSL; no device access.
45
+ plan <paths...> [--device] Show the per-device diff (read-only).
46
+ apply <paths...> [--device] Converge each device to the desired state.
47
+
48
+ PATHS may be DSL files or directories (a directory loads every *.rb under
49
+ it, recursively, in sorted order). plan/apply target ALL devices by default.
50
+
51
+ Options:
52
+ --device NAME Limit plan/apply to device NAME (repeatable; default: all).
53
+ --json plan/validate: emit machine-readable JSON instead of text.
54
+ --auto-approve apply: skip the interactive confirmation (required for CI).
55
+ -h, --help Show this help.
56
+
57
+ apply prompts for confirmation ('yes' to proceed) and REFUSES on a
58
+ non-interactive stdin unless --auto-approve is given.
59
+
60
+ Exit codes: 0 = success/no changes, 1 = error, 2 = plan has changes.
61
+ USAGE
62
+
63
+ # @return [Integer] process exit status
64
+ def self.start(argv = ARGV)
65
+ new.run(argv)
66
+ end
67
+
68
+ # @param out [IO] stream for normal output (test seam)
69
+ # @param err [IO] stream for errors/usage (test seam)
70
+ # @param input [IO] stream the apply confirmation prompt reads from (test
71
+ # seam). Its `#tty?` decides whether stdin is interactive.
72
+ # @param transport_factory [#call] device -> Transport (test seam: inject a
73
+ # stub so plan/apply never touch a real device). Defaults to the
74
+ # DSL-driven adapter built from `device.transport`.
75
+ def initialize(out: $stdout, err: $stderr, input: $stdin, transport_factory: nil)
76
+ @out = out
77
+ @err = err
78
+ @input = input
79
+ @transport_factory = transport_factory || method(:build_transport)
80
+ end
81
+
82
+ # @return [Integer] process exit status
83
+ def run(argv)
84
+ dispatch(argv.dup)
85
+ rescue ConfigurationError, TransportError, PlanError, OptionParser::ParseError => e
86
+ # Known, user-facing failures: a concise message, never a backtrace.
87
+ @err.puts("Error: #{e.message}")
88
+ EXIT_ERROR
89
+ end
90
+
91
+ private
92
+
93
+ def dispatch(argv)
94
+ command = argv.shift
95
+ case command
96
+ when "validate" then validate(argv)
97
+ when "plan" then plan(argv)
98
+ when "apply" then apply(argv)
99
+ when "-h", "--help", "help", nil then print_usage(@out, EXIT_OK)
100
+ else unknown_command(command)
101
+ end
102
+ end
103
+
104
+ def unknown_command(command)
105
+ @err.puts("Unknown command: #{command.inspect}")
106
+ print_usage(@err, EXIT_ERROR)
107
+ end
108
+
109
+ # ── subcommands ────────────────────────────────────────────────────────
110
+
111
+ def validate(argv)
112
+ paths, options = parse(argv, command: "validate")
113
+ return EXIT_OK if options[:help]
114
+
115
+ config = load_config(paths)
116
+ errors = compile_errors(config)
117
+ return emit_validate_json(config, errors) if options[:json]
118
+ return report_validation_errors(errors) unless errors.empty?
119
+
120
+ @out.puts("OK: configuration is valid (#{config.devices.size} device(s) compiled).")
121
+ EXIT_OK
122
+ end
123
+
124
+ def plan(argv)
125
+ paths, options = parse(argv, command: "plan")
126
+ return EXIT_OK if options[:help]
127
+
128
+ config = load_config(paths)
129
+ results = select_devices(config, options[:devices]).map { |device| compute_plan(config, device) }
130
+ options[:json] ? emit_plan_json(results) : emit_plan_human(results)
131
+ plan_exit_code(results)
132
+ end
133
+
134
+ def apply(argv)
135
+ paths, options = parse(argv, command: "apply")
136
+ return EXIT_OK if options[:help]
137
+
138
+ previews = build_apply_previews(load_config(paths), options[:devices])
139
+ print_apply_previews(previews)
140
+ converge_apply(previews, options)
141
+ end
142
+
143
+ def build_apply_previews(config, names)
144
+ select_devices(config, names).map { |device| preview_apply(config, device) }
145
+ end
146
+
147
+ # Prompt (unless skipped), then apply every device that has pending changes.
148
+ def converge_apply(previews, options)
149
+ pending = previews.select { |preview| preview[:status] == :changed }
150
+ return finish_apply(previews) if pending.empty?
151
+ return cancel_apply(previews) unless approved?(options, pending.map { |p| p[:device].name })
152
+
153
+ pending.each { |preview| execute_apply(preview) }
154
+ finish_apply(previews)
155
+ end
156
+
157
+ # ── shared pipeline helpers ────────────────────────────────────────────
158
+
159
+ def load_config(paths)
160
+ raise ConfigurationError, "no DSL paths given" if paths.empty?
161
+
162
+ Mt::Wall.load(*paths)
163
+ end
164
+
165
+ # Compile every device to surface ConfigurationErrors WITHOUT touching a
166
+ # device. Collects per-device failures so the operator sees them all.
167
+ def compile_errors(config)
168
+ config.devices.values.each_with_object([]) do |device, errors|
169
+ Compiler.new(config).compile(device: device)
170
+ rescue ConfigurationError => e
171
+ errors << [device.name, e.message]
172
+ end
173
+ end
174
+
175
+ def report_validation_errors(errors)
176
+ @err.puts("Invalid configuration:")
177
+ errors.each { |name, message| @err.puts(" device #{name}: #{message}") }
178
+ EXIT_ERROR
179
+ end
180
+
181
+ # The selected devices: the subset named via (repeatable) --device, else
182
+ # the whole fleet. An unknown name fails fast with a clear error.
183
+ def select_devices(config, names)
184
+ return config.devices.values if names.nil? || names.empty?
185
+
186
+ names.map do |name|
187
+ config.devices.fetch(name) { raise ConfigurationError, "unknown device #{name.inspect}" }
188
+ end
189
+ end
190
+
191
+ def reconciler_for(config, device)
192
+ transport = @transport_factory.call(device)
193
+ Reconciler.new(configuration: config, device: device, transport: transport)
194
+ end
195
+
196
+ # Default device -> transport mapping, driven entirely by the DSL.
197
+ # Credentials are read from ENV inside the adapter, never here.
198
+ def build_transport(device)
199
+ case device.transport
200
+ when :rest_api then Transport::RestApi.for_device(device)
201
+ when :rsc then Transport::Rsc.new
202
+ else
203
+ raise ConfigurationError,
204
+ "device #{device.name.inspect} uses unsupported transport #{device.transport.inspect}"
205
+ end
206
+ end
207
+
208
+ # ── fleet plan ─────────────────────────────────────────────────────────
209
+
210
+ # Compute one device's plan, tagging the result with a fleet status. A
211
+ # transport failure is captured (not raised) so the rest of the fleet
212
+ # still gets planned and reported.
213
+ def compute_plan(config, device)
214
+ device_plan = reconciler_for(config, device).plan
215
+ { device: device, plan: device_plan, status: device_plan.empty? ? :no_change : :changed }
216
+ rescue TransportError => e
217
+ { device: device, plan: nil, status: :failed, error: e.message }
218
+ end
219
+
220
+ # Aggregate exit code for `plan`: any failure => error; else any changes
221
+ # => EXIT_CHANGES (CI signal); else EXIT_OK.
222
+ def plan_exit_code(results)
223
+ return EXIT_ERROR if results.any? { |r| r[:status] == :failed }
224
+
225
+ results.any? { |r| r[:status] == :changed } ? EXIT_CHANGES : EXIT_OK
226
+ end
227
+
228
+ # ── fleet apply ──────────────────────────────────────────────────────────
229
+
230
+ # Read-only preview phase: compute each device's plan (and keep the
231
+ # reconciler around to execute it after confirmation).
232
+ def preview_apply(config, device)
233
+ reconciler = reconciler_for(config, device)
234
+ device_plan = reconciler.plan
235
+ { device: device, reconciler: reconciler, plan: device_plan,
236
+ status: device_plan.empty? ? :no_change : :changed }
237
+ rescue TransportError => e
238
+ { device: device, reconciler: nil, plan: nil, status: :failed, error: e.message }
239
+ end
240
+
241
+ # Execute one device's apply under the commit-confirm envelope. A failure
242
+ # is recorded and reported, then we move on to the next device.
243
+ def execute_apply(preview)
244
+ applied = preview[:reconciler].apply(preview[:plan])
245
+ print_apply(preview[:device], applied)
246
+ rescue TransportError => e
247
+ preview[:status] = :failed
248
+ preview[:error] = e.message
249
+ @err.puts(" Error applying to #{preview[:device].name}: #{e.message}")
250
+ end
251
+
252
+ def cancel_apply(previews)
253
+ @out.puts("Apply cancelled. No changes were made.")
254
+ @out.puts(rollup_line(previews))
255
+ EXIT_ERROR
256
+ end
257
+
258
+ # Print the rollup and derive the apply exit code: any failed device =>
259
+ # error, else success.
260
+ def finish_apply(previews)
261
+ @out.puts(rollup_line(previews))
262
+ previews.any? { |p| p[:status] == :failed } ? EXIT_ERROR : EXIT_OK
263
+ end
264
+
265
+ # ── confirmation ─────────────────────────────────────────────────────────
266
+
267
+ # Terraform-style gate. `--auto-approve` skips it. A non-interactive stdin
268
+ # without `--auto-approve` is REFUSED (never block waiting for input in
269
+ # CI). Otherwise prompt and accept ONLY an exact "yes".
270
+ def approved?(options, device_names)
271
+ return true if options[:auto_approve]
272
+
273
+ unless interactive?
274
+ @err.puts("Refusing to apply without a TTY; re-run with --auto-approve for non-interactive/CI use.")
275
+ return false
276
+ end
277
+
278
+ @out.print("Apply these changes to #{device_names.join(', ')}? Only 'yes' will be accepted: ")
279
+ @input.gets.to_s.chomp == "yes"
280
+ end
281
+
282
+ def interactive?
283
+ @input.respond_to?(:tty?) && @input.tty?
284
+ end
285
+
286
+ # ── argument parsing ───────────────────────────────────────────────────
287
+
288
+ # @return [Array(Array<String>, Hash)] (paths, options)
289
+ def parse(argv, command:)
290
+ options = {}
291
+ parser = option_parser(command, options)
292
+ paths = parser.parse(argv)
293
+ [paths, options]
294
+ end
295
+
296
+ def option_parser(command, options)
297
+ OptionParser.new do |parser|
298
+ parser.banner = "Usage: mt-wall #{command} [options] <paths...>"
299
+ register_command_options(parser, command, options)
300
+ parser.on("-h", "--help", "Show this help") do
301
+ @out.puts(parser)
302
+ options[:help] = true
303
+ end
304
+ end
305
+ end
306
+
307
+ def register_command_options(parser, command, options)
308
+ register_device_option(parser, options) if %w[plan apply].include?(command)
309
+ if %w[plan validate].include?(command)
310
+ parser.on("--json", "Emit machine-readable JSON") { options[:json] = true }
311
+ end
312
+ return unless command == "apply"
313
+
314
+ parser.on("--auto-approve", "Skip the confirmation prompt (for CI)") { options[:auto_approve] = true }
315
+ end
316
+
317
+ def register_device_option(parser, options)
318
+ options[:devices] = []
319
+ parser.on("--device NAME", "Target device NAME (repeatable; default: all)") do |name|
320
+ options[:devices] << name
321
+ end
322
+ end
323
+
324
+ def print_usage(io, status)
325
+ io.puts(USAGE)
326
+ status
327
+ end
328
+
329
+ # ── output rendering ───────────────────────────────────────────────────
330
+
331
+ # Human-readable fleet plan: a per-device section, then the rollup line.
332
+ def emit_plan_human(results)
333
+ results.each do |result|
334
+ if result[:status] == :failed
335
+ @out.puts(device_header(result[:device]))
336
+ @err.puts(" Error: #{result[:error]}")
337
+ else
338
+ print_plan(result[:device], result[:plan])
339
+ end
340
+ end
341
+ @out.puts(rollup_line(results))
342
+ end
343
+
344
+ # Machine-readable fleet plan: a top-level JSON object keyed by device
345
+ # name, each carrying its host, changed flag, summary counts and the
346
+ # per-path operations. Exit codes are identical to human mode.
347
+ def emit_plan_json(results)
348
+ document = results.to_h { |result| [result[:device].name, device_plan_json(result)] }
349
+ @out.puts(JSON.pretty_generate(document))
350
+ end
351
+
352
+ def device_plan_json(result)
353
+ device = result[:device]
354
+ return { host: device.host, changed: false, error: result[:error] } if result[:status] == :failed
355
+
356
+ plan = result[:plan]
357
+ { host: device.host, changed: !plan.empty?, summary: summary_counts(plan),
358
+ operations: plan.operations.map { |op| operation_json(op) } }
359
+ end
360
+
361
+ def operation_json(operation)
362
+ { action: operation.action, path: operation.path,
363
+ identity: describe_row(operation.payload), comment: operation.payload[:comment],
364
+ payload: stringify_keys(operation.payload) }
365
+ end
366
+
367
+ def stringify_keys(payload)
368
+ payload.to_h { |key, value| [key.to_s, value] }
369
+ end
370
+
371
+ def summary_counts(plan)
372
+ counts = plan.operations.group_by(&:action).transform_values(&:size)
373
+ { create: counts[:create] || 0, update: counts[:update] || 0,
374
+ move: counts[:move] || 0, delete: counts[:delete] || 0, total: plan.operations.size }
375
+ end
376
+
377
+ def emit_validate_json(config, errors)
378
+ if errors.empty?
379
+ @out.puts(JSON.pretty_generate(valid: true, devices: config.devices.keys))
380
+ EXIT_OK
381
+ else
382
+ @out.puts(JSON.pretty_generate(valid: false,
383
+ errors: errors.map { |name, message| { device: name, message: message } }))
384
+ EXIT_ERROR
385
+ end
386
+ end
387
+
388
+ # Show every device's pending plan before prompting; failures are noted so
389
+ # the operator sees them in the preview too.
390
+ def print_apply_previews(previews)
391
+ previews.each do |preview|
392
+ if preview[:status] == :failed
393
+ @out.puts(device_header(preview[:device]))
394
+ @err.puts(" Error: #{preview[:error]}")
395
+ else
396
+ print_plan(preview[:device], preview[:plan])
397
+ end
398
+ end
399
+ end
400
+
401
+ # One-line fleet rollup, e.g.:
402
+ # Devices: 3 | with changes: 1 | no-change: 2 | failed: 0
403
+ def rollup_line(results)
404
+ counts = results.group_by { |r| r[:status] }.transform_values(&:size)
405
+ "Devices: #{results.size} | with changes: #{counts[:changed].to_i} | " \
406
+ "no-change: #{counts[:no_change].to_i} | failed: #{counts[:failed].to_i}"
407
+ end
408
+
409
+ def print_plan(device, device_plan)
410
+ @out.puts(device_header(device))
411
+ return @out.puts(" No changes. Infrastructure is up to date.") if device_plan.empty?
412
+
413
+ print_grouped_operations(device_plan)
414
+ @out.puts(" Plan: #{summary_line(device_plan)}.")
415
+ end
416
+
417
+ def print_grouped_operations(device_plan)
418
+ device_plan.operations.group_by(&:path).each do |path, ops|
419
+ @out.puts(" #{path}")
420
+ ops.each { |op| @out.puts(" #{format_operation(op)}") }
421
+ end
422
+ end
423
+
424
+ def print_apply(device, applied)
425
+ @out.puts(device_header(device))
426
+ if applied.empty?
427
+ @out.puts(" No changes. Nothing to apply.")
428
+ return
429
+ end
430
+
431
+ @out.puts(" Applied #{applied.operations.size} change(s): #{summary_line(applied)}.")
432
+ @out.puts(" Commit-confirm envelope armed and confirmed (device-side auto-revert canceled).")
433
+ end
434
+
435
+ def device_header(device)
436
+ "Device #{device.name} (#{device.host}):"
437
+ end
438
+
439
+ def format_operation(operation)
440
+ glyph = ACTION_GLYPHS.fetch(operation.action, "?")
441
+ "#{glyph} #{operation.action} #{describe_row(operation.payload)}"
442
+ end
443
+
444
+ # A short, human identity for a row: address-list entries by (list, address),
445
+ # filter/nat rules by chain/action plus the operator note from the comment.
446
+ def describe_row(payload)
447
+ return "#{payload[:list]} #{payload[:address]}".strip if payload[:list] || payload[:address]
448
+
449
+ parts = [payload[:chain], payload[:action]].compact
450
+ note = comment_note(payload[:comment])
451
+ parts << "(#{note})" if note
452
+ parts.join(" ")
453
+ end
454
+
455
+ # The operator note half of a rendered comment ("mt-wall:<hash> | note"),
456
+ # falling back to the bare identity tag when there is no note.
457
+ def comment_note(comment)
458
+ return nil if comment.nil?
459
+
460
+ head, tail = comment.split(Compiler::COMMENT_SEPARATOR, 2)
461
+ tail || head
462
+ end
463
+
464
+ def summary_line(device_plan)
465
+ counts = device_plan.operations.group_by(&:action).transform_values(&:size)
466
+ %i[create update move delete].filter_map do |action|
467
+ count = counts[action]
468
+ "#{count} to #{action}" if count
469
+ end.join(", ")
470
+ end
471
+ end
472
+ end
473
+ end