vxlan 0.4.2 → 0.5.1
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/CHANGELOG.md +12 -0
- data/README-zh.md +2 -2
- data/README.md +7 -3
- data/bin/README.md +2 -2
- data/docs/evidence-contracts.md +10 -0
- data/docs/superpowers/plans/2026-10-23-apply-cli-boundaries.md +45 -0
- data/docs/superpowers/plans/2026-10-23-evpn-summary-evidence.md +45 -0
- data/docs/superpowers/specs/2026-10-23-apply-cli-boundaries.md +13 -0
- data/docs/superpowers/specs/2026-10-23-evpn-summary-evidence.md +31 -0
- data/lib/vxlan/change_plan/svi_acl.rb +7 -7
- data/lib/vxlan/change_plan.rb +7 -7
- data/lib/vxlan/cisco_nxos/parsers/show_bgp_l2vpn_evpn_summary.rb +70 -20
- data/lib/vxlan/cli/apply.rb +35 -34
- data/lib/vxlan/cli/collect.rb +4 -4
- data/lib/vxlan/cli/options.rb +42 -41
- data/lib/vxlan/cli/parse.rb +12 -12
- data/lib/vxlan/cli/report.rb +11 -11
- data/lib/vxlan/cli/review/fabric.rb +21 -21
- data/lib/vxlan/cli/review/report.rb +28 -28
- data/lib/vxlan/cli/review.rb +14 -14
- data/lib/vxlan/cli.rb +2 -2
- data/lib/vxlan/fabric/review.rb +42 -42
- data/lib/vxlan/fabric/scope.rb +6 -6
- data/lib/vxlan/recommendations.rb +26 -26
- data/lib/vxlan/version.rb +1 -1
- metadata +19 -21
data/lib/vxlan/cli/apply.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "ctx_cli/command"
|
|
4
4
|
require "ctx_cli/terminal"
|
|
5
5
|
require "ctx_cli/input"
|
|
6
|
+
require "ctx_cli/output"
|
|
6
7
|
require "json"
|
|
7
8
|
require "ctx_runner/document"
|
|
8
9
|
require "digest"
|
|
@@ -25,16 +26,16 @@ module Vxlan
|
|
|
25
26
|
def read_configuration
|
|
26
27
|
[ "terminal length 0", "terminal width 511" ].each { |command| write(command) }
|
|
27
28
|
capture = CtxDriver::Reader.new(session: @native).read("show running-config")
|
|
28
|
-
raise IOError, "
|
|
29
|
+
raise IOError, "Configuration readback failed" unless capture.success?
|
|
29
30
|
observation = Vxlan::Config.new.parse(capture.body, complete: true)
|
|
30
|
-
raise ArgumentError, "
|
|
31
|
+
raise ArgumentError, "Configuration evidence is incomplete" unless observation.complete? && observation.records.one?
|
|
31
32
|
CtxRunner::Document.call(observation.records.first)
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
# 每次只发一条命令;失败立即交还宿主,原始输出不进入终端和日志报告。
|
|
35
36
|
def write(command)
|
|
36
37
|
result = @native.execute_command(command.strip, output_sensitive: true)
|
|
37
|
-
raise IOError, "
|
|
38
|
+
raise IOError, "Device command is unconfirmed" unless result.success? && result.steps.one?
|
|
38
39
|
true
|
|
39
40
|
end
|
|
40
41
|
end
|
|
@@ -45,51 +46,51 @@ module Vxlan
|
|
|
45
46
|
def initialize(env: ENV, out: $stdout, error: $stderr, input: $stdin, connector_factory: nil, discovery: {})
|
|
46
47
|
@env, @out, @error, @input, @factory = env, out, error, input, connector_factory
|
|
47
48
|
@discovery = discovery
|
|
49
|
+
@output = CtxCli::Output.new(out: out, error: error)
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
# 没有 --yes 或自动回退;零退出码只确认配置回读,不声称业务已验收。
|
|
51
53
|
def run(argv)
|
|
52
54
|
@store, @execution = nil, nil
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
p.
|
|
56
|
-
p.on("--
|
|
57
|
-
p.on("--
|
|
58
|
-
p.on("--
|
|
59
|
-
p.on("--
|
|
60
|
-
p.on("
|
|
61
|
-
p.on("-h", "--help", "查看帮助,不连接设备") { options[:help] = true }
|
|
55
|
+
command = CtxCli::Command.new(banner: "Usage: vxlan apply --plan FILE --item ID [--env-file FILE] (requires explicit confirmation in an interactive terminal)",
|
|
56
|
+
defaults: { directory: File.join(Dir.pwd, "vxlan-executions"), port: 22 }, out: @out) do |p, options|
|
|
57
|
+
p.on("--plan FILE", "Batch plan.json; requires a SHA256 checksum file in the same directory") { |value| options[:plan] = value }
|
|
58
|
+
p.on("--item ID", "Explicitly select one change from the batch plan; bulk execution is prohibited") { |value| options[:item] = value }
|
|
59
|
+
p.on("--env-file FILE", "Explicit credentials file; process environment takes precedence") { |value| options[:env_file] = value }
|
|
60
|
+
p.on("--directory DIR", "Private execution log directory") { |value| options[:directory] = value }
|
|
61
|
+
p.on("--port N", Integer, "SSH port, default 22") { |value| options[:port] = value }
|
|
62
|
+
p.on("-h", "--help", "Show help without connecting to devices") { options[:help] = true }
|
|
62
63
|
end
|
|
63
|
-
|
|
64
|
-
return
|
|
64
|
+
options = command.parse(argv)
|
|
65
|
+
return 0 if command.informational?
|
|
65
66
|
terminal = CtxCli::Terminal.new(input: @input, out: @out)
|
|
66
67
|
terminal.require_tty!(@error)
|
|
67
|
-
raise ArgumentError, "
|
|
68
|
+
raise ArgumentError, "A single plan is required" unless options[:plan] && options[:port].between?(1, 65_535)
|
|
68
69
|
body = CtxCli::Input.read(path: options[:plan])
|
|
69
70
|
hash = Digest::SHA256.hexdigest(body)
|
|
70
71
|
plan = CtxParser::Immutable.copy(select_plan(JSON.parse(body), options, hash))
|
|
71
72
|
change = validate_plan(plan)
|
|
72
73
|
host = plan.fetch("device").fetch("host")
|
|
73
|
-
@out.puts "
|
|
74
|
-
@out.puts "
|
|
75
|
-
@out.puts Array(plan["prerequisites"]).map { |value| "
|
|
76
|
-
@out.puts "
|
|
74
|
+
@out.puts "Target: #{plan['device']['name']}(#{host}); #{plan['scope']}; #{plan['title']}"
|
|
75
|
+
@out.puts "Impact: #{plan['impact']}"
|
|
76
|
+
@out.puts Array(plan["prerequisites"]).map { |value| "Prerequisite: #{value}" }
|
|
77
|
+
@out.puts "Plan SHA256: #{hash}\nProposed commands:\n#{change.commands.join("\n")}\nConditional rollback (not executed automatically):\n#{change.rollback.join("\n")}"
|
|
77
78
|
token = "APPLY #{host} #{hash[0, 12]} #{options[:item]}"
|
|
78
|
-
confirmed = terminal.confirm(token: token, prompt: "
|
|
79
|
-
return @out.puts("
|
|
79
|
+
confirmed = terminal.confirm(token: token, prompt: "After confirming the service policy and maintenance window, enter #{token}; any other input cancels: ")
|
|
80
|
+
return @out.puts("Cancelled; no device connection opened.") || 1 unless confirmed
|
|
80
81
|
settings = credentials(options)
|
|
81
82
|
@store = Report.new(options[:directory])
|
|
82
83
|
@execution = CtxRunner::Execution.new(plan: plan, change: change, journal: @store, plan_sha256: hash, item: options[:item])
|
|
83
84
|
@execution.run { |operation| with_session(plan["device"], settings, &operation) }
|
|
84
|
-
@out.puts "
|
|
85
|
+
@out.puts "Configuration readback confirmed; service acceptance is still required. Configuration was not saved to startup configuration. Log: #{@store.directory}/execution.json"
|
|
85
86
|
0
|
|
86
87
|
rescue Interrupt
|
|
87
88
|
report_journal_failure
|
|
88
|
-
@
|
|
89
|
+
@output.diagnostic "Operation interrupted; #{@execution&.started? ? 'Device effects may be incomplete; read back and verify before proceeding. Do not replay commands.' : 'No service configuration was sent.'}"
|
|
89
90
|
130
|
|
90
91
|
rescue StandardError, LoadError => error
|
|
91
92
|
report_journal_failure
|
|
92
|
-
@
|
|
93
|
+
@output.diagnostic "Apply stopped (#{error.class.name}); #{@execution&.started? ? 'Effects are not fully confirmed; read back first. No automatic replay or rollback.' : 'No service configuration was sent; check the terminal, plan, credentials and current configuration for consistency.'}"
|
|
93
94
|
2
|
|
94
95
|
end
|
|
95
96
|
|
|
@@ -100,20 +101,20 @@ module Vxlan
|
|
|
100
101
|
id = options[:item]
|
|
101
102
|
unless document.is_a?(Hash) && document["schema_version"] == 2 && document["platform"] == "cisco_nxos" && document["changes"].is_a?(Hash) &&
|
|
102
103
|
id.is_a?(String) && id.match?(/\A[A-Za-z0-9_.-]+\z/) && document["changes"].key?(id)
|
|
103
|
-
raise ArgumentError, "
|
|
104
|
+
raise ArgumentError, "A batch plan and valid --item are required"
|
|
104
105
|
end
|
|
105
|
-
raise ArgumentError, "
|
|
106
|
+
raise ArgumentError, "Plan checksum verification failed" unless CtxCli::Input.read(path: "#{options[:plan]}.sha256", max_bytes: 128).strip == hash
|
|
106
107
|
document["changes"].fetch(id)
|
|
107
108
|
end
|
|
108
109
|
|
|
109
110
|
# 不信任文件中的命令,重新从有限动作渲染并逐字核对,拒绝额外命令。
|
|
110
111
|
def validate_plan(plan)
|
|
111
|
-
raise ArgumentError, "
|
|
112
|
+
raise ArgumentError, "Plan type mismatch" unless plan.is_a?(Hash) && plan["schema_version"] == 1 && plan["platform"] == "cisco_nxos"
|
|
112
113
|
host = plan.fetch("device").fetch("host")
|
|
113
|
-
raise ArgumentError, "
|
|
114
|
-
raise ArgumentError, "
|
|
114
|
+
raise ArgumentError, "Invalid target address" unless host.is_a?(String) && !host.include?("/") && IPAddr.new(host).to_s == host
|
|
115
|
+
raise ArgumentError, "Invalid configuration fingerprint" unless plan["expected_config_sha256"].is_a?(String) && plan["expected_config_sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
115
116
|
change = ChangePlan.build(plan.fetch("change"))
|
|
116
|
-
raise ArgumentError, "
|
|
117
|
+
raise ArgumentError, "Plan commands were modified" unless change.commands == plan["commands"] && change.rollback == plan["rollback"]
|
|
117
118
|
change
|
|
118
119
|
end
|
|
119
120
|
|
|
@@ -122,7 +123,7 @@ module Vxlan
|
|
|
122
123
|
require "net/connector/config"
|
|
123
124
|
env = Net::Connector::Config.new(name: "vxlan", env: @env, env_file: options[:env_file], **@discovery).load_environment.environment
|
|
124
125
|
username = env["NC_CISCO_NXOS_USERNAME"] || env["NC_DEVICE_USERNAME"]
|
|
125
|
-
raise ArgumentError, "
|
|
126
|
+
raise ArgumentError, "Device username is missing" unless username.is_a?(String) && !username.strip.empty?
|
|
126
127
|
{ username: username, password: env["NC_CISCO_NXOS_PASSWORD"] || env["NC_DEVICE_PASSWORD"], enable_password: env["NC_ENABLE_PASSWORD"],
|
|
127
128
|
port: options[:port], host_key_policy: "strict", command_timeout: 60, login_timeout: 15 }
|
|
128
129
|
end
|
|
@@ -139,8 +140,8 @@ module Vxlan
|
|
|
139
140
|
# 公共执行器保存失败状态;CLI 只呈现持久性尚未确认的诊断。
|
|
140
141
|
def report_journal_failure
|
|
141
142
|
return unless @execution&.journal_failed?
|
|
142
|
-
@
|
|
143
|
-
rescue
|
|
143
|
+
@output.diagnostic "Execution log finalization is unconfirmed; existing checkpoints retained. Do not replay configuration based on this state."
|
|
144
|
+
rescue Interrupt
|
|
144
145
|
nil
|
|
145
146
|
end
|
|
146
147
|
end
|
data/lib/vxlan/cli/collect.rb
CHANGED
|
@@ -34,7 +34,7 @@ module Vxlan
|
|
|
34
34
|
def load_network
|
|
35
35
|
require "net/connector"
|
|
36
36
|
rescue LoadError
|
|
37
|
-
raise LoadError, "
|
|
37
|
+
raise LoadError, "Online collection requires net-connector >= 0.18.0; add it to the host Gemfile or run gem install net-connector", cause: nil
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
# YAML 静态设备或显式 Netdisco 来源二选一;所有重复地址在连接前拒绝。
|
|
@@ -71,7 +71,7 @@ module Vxlan
|
|
|
71
71
|
begin
|
|
72
72
|
Net::Connector::Resources.prepare!(parallel: [ devices.size, options.values.fetch("parallel") ].min)
|
|
73
73
|
rescue ArgumentError
|
|
74
|
-
raise CtxCli::Collection::Error, "
|
|
74
|
+
raise CtxCli::Collection::Error, "Insufficient local file descriptor limit; raise the terminal ulimit -n or lower --parallel. Stopped without connecting to devices.", cause: nil
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
credentials
|
|
@@ -80,14 +80,14 @@ module Vxlan
|
|
|
80
80
|
def report(options)
|
|
81
81
|
Report.new(options.values.fetch("directory"), scripts: options.flags[:scripts] == true)
|
|
82
82
|
rescue ArgumentError
|
|
83
|
-
raise CtxCli::Collection::Error, "
|
|
83
|
+
raise CtxCli::Collection::Error, "Invalid report directory; use a real private directory with mode 0700 and check its path.", cause: nil
|
|
84
84
|
end
|
|
85
85
|
def after_collect(report, options) = report.review_collected_fabric(options.fabric_policy)
|
|
86
86
|
|
|
87
87
|
# 缺失用户名时不尝试连接;密码允许省略以支持明确的 SSH key/agent。
|
|
88
88
|
def credentials!(options)
|
|
89
89
|
username = secret(options, "NC_CISCO_NXOS_USERNAME") || secret(options, "NC_DEVICE_USERNAME")
|
|
90
|
-
raise CtxCli::Collection::Error, "
|
|
90
|
+
raise CtxCli::Collection::Error, "Set NC_DEVICE_USERNAME or NC_CISCO_NXOS_USERNAME; provide a password through the environment or use an SSH key/agent" unless username
|
|
91
91
|
options.values.fetch("ssh").transform_keys(&:to_sym).merge(username: username,
|
|
92
92
|
password: secret(options, "NC_CISCO_NXOS_PASSWORD") || secret(options, "NC_DEVICE_PASSWORD"), enable_password: secret(options, "NC_ENABLE_PASSWORD"))
|
|
93
93
|
end
|
data/lib/vxlan/cli/options.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "optparse"
|
|
|
6
6
|
require "ctx_cli/options"
|
|
7
7
|
require "ctx_cli/terminal"
|
|
8
8
|
require "ctx_cli/initializer"
|
|
9
|
+
gem "net-connector", "~> 0.18.0"
|
|
9
10
|
require "net/connector/inventory/filters"
|
|
10
11
|
require "yaml"
|
|
11
12
|
require "ipaddr"
|
|
@@ -28,7 +29,7 @@ module Vxlan
|
|
|
28
29
|
@explicit_config, @explicit_env_file = config, env_file
|
|
29
30
|
@overrides, @flags = {}, {}
|
|
30
31
|
@parser = parser
|
|
31
|
-
@parser.parse!(argv.dup).tap { |rest| raise ArgumentError, "
|
|
32
|
+
@parser.parse!(argv.dup).tap { |rest| raise ArgumentError, "Positional arguments are not accepted" unless rest.empty? }
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
# 帮助和范例只打印文本,不读取文件或加载可选网络依赖。
|
|
@@ -36,12 +37,12 @@ module Vxlan
|
|
|
36
37
|
if flags[:help] || flags[:help_all]
|
|
37
38
|
@out.puts @parser
|
|
38
39
|
if flags[:help_all]
|
|
39
|
-
@out.puts "
|
|
40
|
-
@out.puts "
|
|
41
|
-
@out.puts "
|
|
40
|
+
@out.puts "Environment defaults: VXLAN_CONFIG, VXLAN_FABRIC, VXLAN_PARALLEL (or NC_PARALLEL), VXLAN_DIRECTORY."
|
|
41
|
+
@out.puts "Credentials: NC_DEVICE_USERNAME / NC_DEVICE_PASSWORD, or NC_CISCO_NXOS_USERNAME / NC_CISCO_NXOS_PASSWORD; optional NC_ENABLE_PASSWORD."
|
|
42
|
+
@out.puts "Precedence: CLI > process environment > YAML > .env > built-in defaults. YAML/.env paths are relative to their source directory; CLI paths are relative to the startup directory. Default reports: #{@directory}"
|
|
42
43
|
end
|
|
43
44
|
elsif flags[:examples]
|
|
44
|
-
@out.puts "
|
|
45
|
+
@out.puts "Common examples (replace addresses with actual inventory addresses):\n Initialize missing files and exit:\n #{@command} --init --non-interactive\n Preview filtered inventory:\n #{@command} --host 192.0.2.10 --plan\n Select devices by name:\n #{@command} --name leaf --exclude-name spare -p 4\n Automated collection summary:\n #{@command} --dimensions configuration,nve_peers --json --no-progress"
|
|
45
46
|
else
|
|
46
47
|
return false
|
|
47
48
|
end
|
|
@@ -51,7 +52,7 @@ module Vxlan
|
|
|
51
52
|
# 自动化拒绝强制问答;帮助分支无需检查终端或消费输入。
|
|
52
53
|
def validate_interaction!(input:, error:)
|
|
53
54
|
if flags[:interactive] && !CtxCli::Terminal.tty?(input, error)
|
|
54
|
-
raise CtxCli::Collection::Error, "--interactive
|
|
55
|
+
raise CtxCli::Collection::Error, "--interactive requires an interactive terminal; use --init --non-interactive for automation"
|
|
55
56
|
end
|
|
56
57
|
end
|
|
57
58
|
|
|
@@ -66,7 +67,7 @@ module Vxlan
|
|
|
66
67
|
@environment = @files.environment
|
|
67
68
|
@config, @env_file = @files.config, @files.env_file
|
|
68
69
|
rescue LoadError
|
|
69
|
-
raise LoadError, "
|
|
70
|
+
raise LoadError, "Collection configuration requires net-connector >= 0.18.0; reading .env also requires dotenv", cause: nil
|
|
70
71
|
end
|
|
71
72
|
|
|
72
73
|
# 公共与专用文件先合并,进程值和 CLI 最后覆盖;数组不追加设备。
|
|
@@ -74,7 +75,7 @@ module Vxlan
|
|
|
74
75
|
@files.load
|
|
75
76
|
@config, @env_file = @files.config, @files.env_file
|
|
76
77
|
data = Net::Connector::Config.merge(@files.data)
|
|
77
|
-
raise ArgumentError, "YAML
|
|
78
|
+
raise ArgumentError, "YAML accepts only public configuration fields" unless (data.keys - FIELDS).empty?
|
|
78
79
|
specific = @files.specific
|
|
79
80
|
if specific["devices"].is_a?(Array) && specific["devices"].any? && (specific.keys & %w[netdisco inventory netdisco_config]).empty?
|
|
80
81
|
data = data.except("netdisco", "inventory")
|
|
@@ -92,14 +93,14 @@ module Vxlan
|
|
|
92
93
|
netdisco_settings if dynamic_inventory?
|
|
93
94
|
@fabric_policy = nil
|
|
94
95
|
if values["fabric_policy"]
|
|
95
|
-
raise ArgumentError, "fabric_policy
|
|
96
|
+
raise ArgumentError, "fabric_policy must be a path" unless values["fabric_policy"].is_a?(String)
|
|
96
97
|
policy = YAML.safe_load(CtxCli::Input.read(path: values["fabric_policy"]), aliases: false)
|
|
97
98
|
Vxlan::Fabric::Scope.new(policy)
|
|
98
99
|
@fabric_policy = CtxParser::Immutable.copy(policy)
|
|
99
100
|
end
|
|
100
101
|
self
|
|
101
102
|
rescue Psych::Exception
|
|
102
|
-
raise ArgumentError, "YAML
|
|
103
|
+
raise ArgumentError, "Invalid YAML or disallowed types", cause: nil
|
|
103
104
|
end
|
|
104
105
|
|
|
105
106
|
# 对外只列来源路径和字段归属,不携带环境文件的值。
|
|
@@ -157,9 +158,9 @@ module Vxlan
|
|
|
157
158
|
Net::Connector::Config.dump(document, name: "vxlan", path: config)
|
|
158
159
|
{ config => template, env_file => File.read(File.join(__dir__, "templates/env.example")) }.each do |path, body|
|
|
159
160
|
status = CtxCli::Initializer.new(prefix: ".vxlan-init-").create(path: path, contents: body)
|
|
160
|
-
@out.puts "#{status == :created ? '
|
|
161
|
+
@out.puts "#{status == :created ? 'created' : 'preserved'}: #{path}"
|
|
161
162
|
end
|
|
162
|
-
@out.puts "
|
|
163
|
+
@out.puts "Check configuration and credentials, then run again; inventory was not read and no devices were connected."
|
|
163
164
|
end
|
|
164
165
|
|
|
165
166
|
# 默认仅在真实终端提供初始化选择;自动化从不消费 stdin。
|
|
@@ -169,20 +170,20 @@ module Vxlan
|
|
|
169
170
|
return false unless @files.missing?
|
|
170
171
|
terminal = CtxCli::Terminal.new(input: input, out: error)
|
|
171
172
|
if terminal.tty? && flags[:interactive] != false
|
|
172
|
-
answer = terminal.question("
|
|
173
|
+
answer = terminal.question("Configuration does not exist: #{config}; create configuration and environment templates? [y/N] ")
|
|
173
174
|
if answer&.match?(/\Ay(?:es)?\z/i)
|
|
174
175
|
initialize_files
|
|
175
176
|
return true
|
|
176
177
|
end
|
|
177
178
|
end
|
|
178
|
-
raise ArgumentError, "
|
|
179
|
+
raise ArgumentError, "Configuration does not exist: #{config}; run --init and fill in devices, or select configuration with --config"
|
|
179
180
|
end
|
|
180
181
|
|
|
181
182
|
# 公开配置输出与实际清单复用身份预检,不读取远端清单。
|
|
182
183
|
def normalize_devices(rows)
|
|
183
|
-
raise ArgumentError, "devices
|
|
184
|
+
raise ArgumentError, "devices must be an array" unless rows.is_a?(Array)
|
|
184
185
|
targets = rows.map { |row| normalize_device(row) }
|
|
185
|
-
raise ArgumentError, "
|
|
186
|
+
raise ArgumentError, "Device inventory contains duplicate management addresses" unless targets.map { |row| row.fetch("host") }.uniq.size == targets.size
|
|
186
187
|
targets
|
|
187
188
|
end
|
|
188
189
|
|
|
@@ -211,20 +212,20 @@ module Vxlan
|
|
|
211
212
|
# 设备身份只接受公开字段,拒绝隐藏在 YAML 中的密码或连接参数。
|
|
212
213
|
def normalize_device(row)
|
|
213
214
|
unless row.is_a?(Hash) && (row.keys - %w[host name model]).empty? && row["host"].is_a?(String) && !row["host"].include?("/")
|
|
214
|
-
raise ArgumentError, "devices
|
|
215
|
+
raise ArgumentError, "devices accepts only host, name and model; host must be a single IP address"
|
|
215
216
|
end
|
|
216
217
|
host = IPAddr.new(row.fetch("host")).to_s
|
|
217
|
-
%w[name model].each { |key| raise ArgumentError, "
|
|
218
|
+
%w[name model].each { |key| raise ArgumentError, "Device #{key} must be a nonempty string" if row.key?(key) && !row[key].nil? && (!row[key].is_a?(String) || row[key].strip.empty?) }
|
|
218
219
|
{ "host" => host, "name" => row["name"] || host, "model" => row["model"] }.freeze
|
|
219
220
|
rescue IPAddr::Error
|
|
220
|
-
raise ArgumentError, "
|
|
221
|
+
raise ArgumentError, "Invalid device host", cause: nil
|
|
221
222
|
end
|
|
222
223
|
|
|
223
224
|
# 显式声明短参数与长参数;不提供难以发现的兼容缩写。
|
|
224
225
|
def parser
|
|
225
226
|
OptionParser.new do |p|
|
|
226
|
-
p.banner = "
|
|
227
|
-
CtxCli::Options.configuration(p, parallel_help: "
|
|
227
|
+
p.banner = "Usage: #{@command} [options] (read-only VXLAN collection)"
|
|
228
|
+
CtxCli::Options.configuration(p, parallel_help: "Device concurrency 1 to 500; inherits NC_PARALLEL, defaults to 4") do |key, value|
|
|
228
229
|
case key
|
|
229
230
|
when :config then @explicit_config = File.expand_path(value, Dir.pwd)
|
|
230
231
|
when :env_file then @explicit_env_file = File.expand_path(value, Dir.pwd)
|
|
@@ -232,9 +233,9 @@ module Vxlan
|
|
|
232
233
|
when :parallel then @overrides["parallel"] = value
|
|
233
234
|
end
|
|
234
235
|
end
|
|
235
|
-
p.on("--fabric-policy FILE", "
|
|
236
|
-
p.on("--fabric NAME", "
|
|
237
|
-
p.on("--dimensions LIST", "
|
|
236
|
+
p.on("--fabric-policy FILE", "Explicit Fabric/Leaf/Border/Spine inventory YAML") { |v| @overrides["fabric_policy"] = File.expand_path(v) }
|
|
237
|
+
p.on("--fabric NAME", "Fabric identity for this collection") { |v| @overrides["fabric"] = v }
|
|
238
|
+
p.on("--dimensions LIST", "Comma-separated dimensions; see --help for supported values") { |v| @overrides["dimensions"] = list(v) }
|
|
238
239
|
Net::Connector::Inventory::Filters.add_options(p) do |key, value|
|
|
239
240
|
if value.is_a?(Array)
|
|
240
241
|
(@overrides[key] ||= []).concat(value)
|
|
@@ -243,13 +244,13 @@ module Vxlan
|
|
|
243
244
|
end
|
|
244
245
|
end
|
|
245
246
|
CtxCli::Options.initialization(p, flags)
|
|
246
|
-
p.on("--plan", "--inventory-only", "
|
|
247
|
-
p.on("--show-config", "
|
|
247
|
+
p.on("--plan", "--inventory-only", "Preview filtered inventory without connecting to devices") { flags[:inventory_only] = true }
|
|
248
|
+
p.on("--show-config", "Show effective public configuration") { flags[:show_config] = true }
|
|
248
249
|
CtxCli::Options.output(p, flags)
|
|
249
|
-
p.on("--[no-]scripts", "
|
|
250
|
+
p.on("--[no-]scripts", "Optionally export configuration suggestions; diagnostics only by default") { |value| flags[:scripts] = value }
|
|
250
251
|
CtxCli::Options.information(p, flags)
|
|
251
|
-
p.separator "
|
|
252
|
-
p.separator "
|
|
252
|
+
p.separator "Dimensions: #{Vxlan::Collector::COMMANDS.keys.join(', ')}"
|
|
253
|
+
p.separator "Precedence: CLI > process environment > YAML > .env > built-in defaults. Exit codes: 0 complete/preview; 1 incomplete/no targets; 2 argument/persistence error; 130 cancelled."
|
|
253
254
|
end
|
|
254
255
|
end
|
|
255
256
|
|
|
@@ -258,40 +259,40 @@ module Vxlan
|
|
|
258
259
|
|
|
259
260
|
# 先验证整批策略,避免在前面设备连接后才发现后面的非法配置。
|
|
260
261
|
def validate!
|
|
261
|
-
raise ArgumentError, "parallel
|
|
262
|
-
%w[fabric directory].each { |key| raise ArgumentError, "#{key}
|
|
262
|
+
raise ArgumentError, "parallel must be 1 to 500" unless values["parallel"].is_a?(Integer) && (1..500).cover?(values["parallel"])
|
|
263
|
+
%w[fabric directory].each { |key| raise ArgumentError, "#{key} must be a nonempty string" unless values[key].is_a?(String) && !values[key].strip.empty? }
|
|
263
264
|
dimensions = values["dimensions"]
|
|
264
265
|
unless dimensions.is_a?(Array) && dimensions.any? && dimensions.all?(String) && dimensions.uniq == dimensions && (dimensions - Vxlan::Collector::COMMANDS.keys.map(&:to_s)).empty?
|
|
265
|
-
raise ArgumentError, "dimensions
|
|
266
|
+
raise ArgumentError, "dimensions contains unknown, duplicate or empty dimensions"
|
|
266
267
|
end
|
|
267
268
|
values["devices"] = normalize_devices(values["devices"])
|
|
268
269
|
inline = %w[netdisco inventory].any? { |key| values.key?(key) }
|
|
269
270
|
%w[netdisco inventory].each do |key|
|
|
270
|
-
raise ArgumentError, "#{key}
|
|
271
|
+
raise ArgumentError, "#{key} must be a mapping" if values.key?(key) && !values[key].is_a?(Hash)
|
|
271
272
|
end
|
|
272
273
|
if inline && (!values["devices"].empty? || values["netdisco_config"])
|
|
273
|
-
raise ArgumentError, "
|
|
274
|
+
raise ArgumentError, "Static devices, inline Netdisco and external netdisco_config are mutually exclusive"
|
|
274
275
|
end
|
|
275
276
|
if values["netdisco_config"]
|
|
276
|
-
raise ArgumentError, "netdisco_config
|
|
277
|
+
raise ArgumentError, "netdisco_config must be a configuration path and cannot be combined with devices" unless values["netdisco_config"].is_a?(String) && !values["netdisco_config"].empty? && values["devices"].empty?
|
|
277
278
|
values["netdisco_config"] = File.expand_path(values["netdisco_config"], Dir.pwd)
|
|
278
279
|
end
|
|
279
280
|
Net::Connector::Inventory::Filters::LISTS.each_key { |key| values[key] = values.fetch(key, []) }
|
|
280
281
|
Net::Connector::Inventory::Filters.selection(values)
|
|
281
282
|
ssh = values["ssh"]
|
|
282
|
-
raise ArgumentError, "ssh
|
|
283
|
+
raise ArgumentError, "ssh contains unknown or sensitive fields" unless ssh.is_a?(Hash) && (ssh.keys - SSH_FIELDS).empty?
|
|
283
284
|
ssh = SSH_DEFAULTS.merge(ssh)
|
|
284
|
-
raise ArgumentError, "host_key_policy
|
|
285
|
-
%w[command_timeout login_timeout].each { |key| raise ArgumentError, "#{key}
|
|
286
|
-
raise ArgumentError, "port
|
|
285
|
+
raise ArgumentError, "host_key_policy must be strict or accept_new" unless %w[strict accept_new].include?(ssh["host_key_policy"])
|
|
286
|
+
%w[command_timeout login_timeout].each { |key| raise ArgumentError, "#{key} must be positive" unless ssh[key].is_a?(Numeric) && ssh[key].finite? && ssh[key].positive? }
|
|
287
|
+
raise ArgumentError, "port must be 1 to 65535" if ssh.key?("port") && (!ssh["port"].is_a?(Integer) || !(1..65535).cover?(ssh["port"]))
|
|
287
288
|
if ssh.key?("known_hosts")
|
|
288
|
-
raise ArgumentError, "known_hosts
|
|
289
|
+
raise ArgumentError, "known_hosts must be a path" unless ssh["known_hosts"].is_a?(String) && !ssh["known_hosts"].empty?
|
|
289
290
|
ssh["known_hosts"] = File.expand_path(ssh["known_hosts"], Dir.pwd)
|
|
290
291
|
end
|
|
291
292
|
values["ssh"] = ssh
|
|
292
293
|
values["directory"] = File.expand_path(values["directory"], Dir.pwd)
|
|
293
294
|
rescue IPAddr::Error
|
|
294
|
-
raise ArgumentError, "hosts
|
|
295
|
+
raise ArgumentError, "hosts must contain IPs or CIDRs; addresses are not expanded and subnets are not scanned", cause: nil
|
|
295
296
|
end
|
|
296
297
|
end
|
|
297
298
|
end
|
data/lib/vxlan/cli/parse.rb
CHANGED
|
@@ -20,18 +20,18 @@ module Vxlan
|
|
|
20
20
|
|
|
21
21
|
# 参数错误与部分证据分开返回;默认只显示元数据,JSON 显式导出事实。
|
|
22
22
|
def run(argv)
|
|
23
|
-
command = CtxCli::Command.new(banner: "
|
|
24
|
-
p.on("--file FILE", "
|
|
25
|
-
p.on("--dimension NAME", "configuration
|
|
26
|
-
p.on("--model NAME", "
|
|
27
|
-
p.on("--complete", "
|
|
28
|
-
p.on("--json", "
|
|
23
|
+
command = CtxCli::Command.new(banner: "Usage: #{@command} [options] (offline file or stdin)", defaults: { dimension: :configuration, complete: false }, out: @out) do |p, options|
|
|
24
|
+
p.on("--file FILE", "Read a file; defaults to stdin") { |v| options[:file] = v }
|
|
25
|
+
p.on("--dimension NAME", "configuration or a runtime dimension") { |v| options[:dimension] = v.to_sym }
|
|
26
|
+
p.on("--model NAME", "Device model") { |v| options[:model] = v }
|
|
27
|
+
p.on("--complete", "Declare input fully collected; missing parse evidence is still reported") { options[:complete] = true }
|
|
28
|
+
p.on("--json", "Output facts as JSON; may contain sensitive configuration") { options[:json] = true }
|
|
29
29
|
CtxCli::Options.information(p, options)
|
|
30
|
-
p.separator "
|
|
30
|
+
p.separator "Dimensions: #{Vxlan::Collector::COMMANDS.keys.join(', ')}"
|
|
31
31
|
end
|
|
32
32
|
options = command.parse(argv)
|
|
33
33
|
return 0 if command.informational?(examples: "#{@command} --file running.cfg --complete\ncat nve.txt | #{@command} --dimension nve_peers --complete --json")
|
|
34
|
-
raise ArgumentError, "
|
|
34
|
+
raise ArgumentError, "Unknown dimension" unless Vxlan::Collector::COMMANDS.key?(options[:dimension])
|
|
35
35
|
|
|
36
36
|
body = CtxCli::Input.read(path: options[:file], input: @input)
|
|
37
37
|
observation = if options[:dimension] == :configuration
|
|
@@ -42,13 +42,13 @@ module Vxlan
|
|
|
42
42
|
if options[:json]
|
|
43
43
|
@output.json(observation.to_h)
|
|
44
44
|
else
|
|
45
|
-
@out.puts "
|
|
46
|
-
@out.puts "
|
|
47
|
-
@out.puts "
|
|
45
|
+
@out.puts "Dimensions: #{observation.name}; evidence: #{observation.status}; records: #{observation.records.size}"
|
|
46
|
+
@out.puts "Diagnostics: #{observation.issues.map { |issue| issue[:code] }.uniq.join(', ')}" unless observation.issues.empty?
|
|
47
|
+
@out.puts "Complete evidence does not establish Fabric health."
|
|
48
48
|
end
|
|
49
49
|
observation.complete? ? 0 : 1
|
|
50
50
|
rescue OptionParser::ParseError, ArgumentError, SystemCallError, IOError => error
|
|
51
|
-
@output.diagnostic("
|
|
51
|
+
@output.diagnostic("Offline parsing argument or file error; use --help for options", exception: error)
|
|
52
52
|
2
|
|
53
53
|
end
|
|
54
54
|
end
|
data/lib/vxlan/cli/report.rb
CHANGED
|
@@ -50,19 +50,19 @@ module Vxlan
|
|
|
50
50
|
def finish(results, interrupted: false, requested: [], report_error: nil, execution_error: nil, output_error: nil)
|
|
51
51
|
@report.finish(results, interrupted: interrupted, requested: requested, report_error: report_error,
|
|
52
52
|
execution_error: execution_error, output_error: output_error, review: @review_summary) do |summary|
|
|
53
|
-
pending = summary.fetch(
|
|
54
|
-
text = [ "VXLAN
|
|
55
|
-
text << "
|
|
53
|
+
pending = summary.fetch("pending")
|
|
54
|
+
text = [ "VXLAN evidence: #{summary.fetch('status')}; completed #{summary.fetch('completed')} devices", "Categories: #{summary.fetch('counts')}", "Complete evidence does not establish Fabric health." ]
|
|
55
|
+
text << "Per-device diagnostics: devices/; candidate scripts: scripts/; no candidate configuration was executed."
|
|
56
56
|
text << @review_text if @review_text
|
|
57
|
-
text << "
|
|
58
|
-
text << "
|
|
59
|
-
text << "
|
|
60
|
-
text << "
|
|
61
|
-
|
|
62
|
-
saved = r[
|
|
63
|
-
text << "#{r[
|
|
57
|
+
text << "Batch report status: see summary.json; evidence status in text does not confirm report publication."
|
|
58
|
+
text << "Report error: #{summary['report_error']}" if summary["report_error"]
|
|
59
|
+
text << "Execution error: #{summary['execution_error']}" if summary["execution_error"]
|
|
60
|
+
text << "Progress output error: #{summary['output_error']['error_type']}; device results retained" if summary["output_error"]
|
|
61
|
+
summary.fetch("devices").each do |r|
|
|
62
|
+
saved = r["report_saved"] == true ? "saved" : "persistence unconfirmed"
|
|
63
|
+
text << "#{r['device']['host']}\t#{r['device']['name']}\t#{r['status']}\t#{saved}\t#{r['error']}"
|
|
64
64
|
end
|
|
65
|
-
pending.each { |device| text << "#{device['host']}\t#{device['name']}\
|
|
65
|
+
pending.each { |device| text << "#{device['host']}\t#{device['name']}\tnot collected" }
|
|
66
66
|
text.join("\n") + "\n"
|
|
67
67
|
end
|
|
68
68
|
end
|
|
@@ -13,35 +13,35 @@ module Vxlan
|
|
|
13
13
|
# 相同事实合并展示;规则版本保持匹配顺序,完整证据由聚合计划保存。
|
|
14
14
|
def fabric_text(audit, documents)
|
|
15
15
|
by_host = documents.to_h { |document| [ document.fetch("device").fetch("host"), document ] }
|
|
16
|
-
lines = [ "Fabric
|
|
16
|
+
lines = [ "Fabric shared-service consistency", "Scope: compare shared VNIs within explicit Fabrics and roles; compare Leaf and Border separately, and check Spine for service-bearing configuration.", "Deployment facts combine VLAN/NVE/EVPN evidence; incomplete deployment does not mean absence. Deployment scope differences are retained.", "ACL comparison includes ordered rules and dependencies; a majority without an ACL is not grounds for removal.", "" ]
|
|
17
17
|
inventory = audit[:inventory]
|
|
18
18
|
if inventory
|
|
19
|
-
lines << "
|
|
19
|
+
lines << "Coverage: included #{inventory[:assigned].size} devices; excluded #{inventory[:unassigned].size} devices; missing evidence #{inventory[:missing].size} devices."
|
|
20
20
|
inventory[:groups].each do |fabric, roles|
|
|
21
|
-
lines << "#{fabric}
|
|
21
|
+
lines << "#{fabric}: Leaf #{roles['leaves'].size} / Border #{roles['borders'].size} / Spine #{roles['spines'].size}"
|
|
22
22
|
%w[leaves borders].each do |role|
|
|
23
23
|
services = audit[:coverage].select { |entry| entry[:fabric] == fabric && entry[:role] == role }
|
|
24
24
|
partial = services.select { |entry| entry[:incomplete].any? }
|
|
25
|
-
lines << " #{role}
|
|
26
|
-
partial.each { |entry| lines << " VNI #{entry[:vni]}
|
|
25
|
+
lines << " #{role}: deployed services #{services.size}, services shared across devices #{services.count { |entry| entry[:deployed].size >= 2 }}, incomplete associations #{partial.size}."
|
|
26
|
+
partial.each { |entry| lines << " VNI #{entry[:vni]} missing: #{entry[:incomplete].map { |host| by_host.dig(host, 'device', 'name') || host }.join(', ')}" }
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
lines << ""
|
|
30
30
|
end
|
|
31
31
|
audit[:findings].each_with_index do |finding, index|
|
|
32
|
-
lines << "#{index + 1}. #{finding[:fabric]} / #{finding[:role] || '
|
|
32
|
+
lines << "#{index + 1}. #{finding[:fabric]} / #{finding[:role] || 'entire Fabric'} / #{finding[:vni] ? "VNI #{finding[:vni]}" : 'shared policy'}: #{finding[:title]}"
|
|
33
33
|
end
|
|
34
34
|
audit[:findings].each_with_index do |finding, index|
|
|
35
|
-
lines.concat([ "", "[#{index + 1}] #{finding[:fabric]} / #{finding[:role] || '
|
|
35
|
+
lines.concat([ "", "[#{index + 1}] #{finding[:fabric]} / #{finding[:role] || 'entire Fabric'} / #{finding[:vni] || 'shared policy'} — #{finding[:title]}", "Rule: #{finding[:code]}" ])
|
|
36
36
|
evidence = finding[:evidence]
|
|
37
37
|
if finding[:code] == "fabric_address_group"
|
|
38
38
|
defined = evidence.filter_map { |row| row[:value] }
|
|
39
39
|
common = defined.reduce { |all, members| all & members } || []
|
|
40
|
-
lines << "
|
|
40
|
+
lines << " Members common to defined versions (not an approved baseline): #{common.empty? ? 'none' : common.join(', ')}"
|
|
41
41
|
evidence.group_by { |row| row[:value] }.each do |members, rows|
|
|
42
42
|
names = rows.map { |row| by_host.dig(row[:host], "device", "name") || row[:host] }
|
|
43
|
-
value = members ? "#{members.size}
|
|
44
|
-
lines.concat([ " #{names.join('
|
|
43
|
+
value = members ? "#{members.size} members; outside the common set: #{(members - common).empty? ? 'none' : (members - common).join(', ')}" : "No definition provided or object evidence not collected"
|
|
44
|
+
lines.concat([ " #{names.join(', ')}", " #{value}" ])
|
|
45
45
|
end
|
|
46
46
|
next
|
|
47
47
|
end
|
|
@@ -53,18 +53,18 @@ module Vxlan
|
|
|
53
53
|
value = row.key?(:value) ? row[:value] : row.reject { |key, _| %i[host name vlan entries semantics].include?(key) }
|
|
54
54
|
value = value.reject { |key, _| key == :entries } if value.is_a?(Hash)
|
|
55
55
|
if row[:semantics]
|
|
56
|
-
policy = row[:policy] ? "
|
|
57
|
-
value = "#{row[:entries].size}
|
|
56
|
+
policy = row[:policy] ? "Rule and dependency version #{CtxRunner::Fingerprint.call(row[:policy])[0, 12]}" : "Dependencies unverified"
|
|
57
|
+
value = "#{row[:entries].size} rules, #{policy}"
|
|
58
58
|
end
|
|
59
|
-
[ row[:vlan] || row[:interface] || "
|
|
59
|
+
[ row[:vlan] || row[:interface] || "shared", display_fabric_value(value) ]
|
|
60
60
|
end
|
|
61
61
|
grouped.each do |(vlan, value), members|
|
|
62
62
|
names = members.map { |row| row[:name] || by_host.dig(row[:host], "device", "name") || row[:host] }
|
|
63
|
-
lines.concat([ " VLAN
|
|
63
|
+
lines.concat([ " VLAN/interface #{vlan}: #{value}", " #{names.join(', ')}" ])
|
|
64
64
|
end
|
|
65
65
|
if evidence.any? { |row| row[:semantics] }
|
|
66
66
|
evidence.group_by { |row| row[:semantics] }.each_with_index do |(rules, members), variant|
|
|
67
|
-
lines << "
|
|
67
|
+
lines << " Rule version #{variant + 1}: #{members.map { |row| by_host.dig(row[:host], 'device', 'name') || row[:host] }.join(', ')}"
|
|
68
68
|
lines.concat(rules.map { |rule| " #{rule['action']} #{rule['expression']}" })
|
|
69
69
|
end
|
|
70
70
|
end
|
|
@@ -75,14 +75,14 @@ module Vxlan
|
|
|
75
75
|
# 机器字段转换为运维读法,显式保留缺失和未知。
|
|
76
76
|
def display_fabric_value(value)
|
|
77
77
|
if value.is_a?(Hash) && value.key?(:acl)
|
|
78
|
-
return "
|
|
79
|
-
return "#{value[:acl]}
|
|
80
|
-
return "#{value[:acl]}
|
|
81
|
-
"#{value[:acl]}
|
|
78
|
+
return "No ACL attached" unless value[:acl]
|
|
79
|
+
return "#{value[:acl]}; local definition missing" unless value[:defined]
|
|
80
|
+
return "#{value[:acl]}; defined, but dependencies unverified" unless value[:policy_verified]
|
|
81
|
+
"#{value[:acl]}; defined, version #{value[:digest].to_s[0, 12]}"
|
|
82
82
|
elsif value.is_a?(Array)
|
|
83
|
-
value.empty? ? "
|
|
83
|
+
value.empty? ? "No address configured" : value.join(", ")
|
|
84
84
|
elsif value.nil?
|
|
85
|
-
"
|
|
85
|
+
"not provided"
|
|
86
86
|
elsif value.is_a?(Hash)
|
|
87
87
|
JSON.generate(value)
|
|
88
88
|
else
|