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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +55 -0
- data/LICENSE.txt +21 -0
- data/README.md +166 -0
- data/docs/dsl-reference.md +388 -0
- data/docs/gitops.md +173 -0
- data/docs/security.md +142 -0
- data/examples/README.md +67 -0
- data/examples/config/devices/edge-1.rb +44 -0
- data/examples/config/devices/edge-2.rb +41 -0
- data/examples/config/objects.rb +46 -0
- data/examples/config/policy.rb +30 -0
- data/examples/config/services.rb +19 -0
- data/exe/mt-wall +6 -0
- data/lib/mt/wall/cli.rb +473 -0
- data/lib/mt/wall/compiler.rb +613 -0
- data/lib/mt/wall/configuration.rb +123 -0
- data/lib/mt/wall/desired_state.rb +200 -0
- data/lib/mt/wall/dsl/chain_builder.rb +112 -0
- data/lib/mt/wall/dsl/device_builder.rb +149 -0
- data/lib/mt/wall/dsl/group_builder.rb +36 -0
- data/lib/mt/wall/dsl/host_builder.rb +64 -0
- data/lib/mt/wall/dsl/nat_builder.rb +114 -0
- data/lib/mt/wall/dsl/policy_scope.rb +31 -0
- data/lib/mt/wall/dsl/root_builder.rb +141 -0
- data/lib/mt/wall/dsl/rule_builder.rb +86 -0
- data/lib/mt/wall/dsl/rule_scope.rb +35 -0
- data/lib/mt/wall/dsl/validators.rb +306 -0
- data/lib/mt/wall/dsl.rb +61 -0
- data/lib/mt/wall/errors.rb +19 -0
- data/lib/mt/wall/model/address_object.rb +35 -0
- data/lib/mt/wall/model/device.rb +54 -0
- data/lib/mt/wall/model/filter_rule.rb +66 -0
- data/lib/mt/wall/model/group.rb +27 -0
- data/lib/mt/wall/model/nat_rule.rb +49 -0
- data/lib/mt/wall/model/policy.rb +27 -0
- data/lib/mt/wall/model/rule.rb +50 -0
- data/lib/mt/wall/model/service.rb +42 -0
- data/lib/mt/wall/plan.rb +304 -0
- data/lib/mt/wall/reconciler.rb +148 -0
- data/lib/mt/wall/transport/base.rb +79 -0
- data/lib/mt/wall/transport/rest_api.rb +464 -0
- data/lib/mt/wall/transport/rsc.rb +99 -0
- data/lib/mt/wall/version.rb +7 -0
- data/lib/mt/wall.rb +56 -0
- metadata +91 -0
data/lib/mt/wall/cli.rb
ADDED
|
@@ -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
|