vxlan 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -7
- data/README-zh.md +1 -1
- data/README.md +3 -1
- data/bin/README.md +2 -2
- data/docs/superpowers/plans/2026-10-22-ctx-cli.md +138 -0
- data/docs/superpowers/specs/2026-10-22-ctx-cli-design.md +123 -0
- data/lib/vxlan/change_plan/svi_acl.rb +7 -7
- data/lib/vxlan/change_plan.rb +8 -15
- data/lib/vxlan/cisco_nxos/parsers/show_bgp_l2vpn_evpn_summary.rb +2 -2
- data/lib/vxlan/cisco_nxos/parsers/show_interface_brief.rb +1 -1
- data/lib/vxlan/cisco_nxos/parsers/show_port_channel_summary.rb +1 -1
- data/lib/vxlan/cisco_nxos/parsers/show_running_config.rb +2 -2
- data/lib/vxlan/cisco_nxos/syntax/policies.rb +1 -1
- data/lib/vxlan/cisco_nxos/syntax/routing.rb +1 -1
- data/lib/vxlan/cli/apply.rb +32 -30
- data/lib/vxlan/cli/collect.rb +13 -5
- data/lib/vxlan/cli/options.rb +47 -44
- data/lib/vxlan/cli/parse.rb +12 -12
- data/lib/vxlan/cli/report.rb +14 -14
- data/lib/vxlan/cli/review/fabric.rb +24 -22
- data/lib/vxlan/cli/review/report.rb +31 -30
- data/lib/vxlan/cli/review.rb +42 -73
- data/lib/vxlan/cli.rb +4 -4
- data/lib/vxlan/collection_plan.rb +1 -1
- data/lib/vxlan/fabric/review.rb +43 -43
- data/lib/vxlan/fabric/scope.rb +6 -6
- data/lib/vxlan/recommendations.rb +26 -26
- data/lib/vxlan/version.rb +1 -1
- metadata +17 -21
data/lib/vxlan/cli/apply.rb
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
4
|
require "ctx_cli/terminal"
|
|
5
|
+
require "ctx_cli/input"
|
|
5
6
|
require "json"
|
|
7
|
+
require "ctx_runner/document"
|
|
6
8
|
require "digest"
|
|
7
9
|
require "ctx_runner/execution"
|
|
8
10
|
require "ipaddr"
|
|
@@ -23,16 +25,16 @@ module Vxlan
|
|
|
23
25
|
def read_configuration
|
|
24
26
|
[ "terminal length 0", "terminal width 511" ].each { |command| write(command) }
|
|
25
27
|
capture = CtxDriver::Reader.new(session: @native).read("show running-config")
|
|
26
|
-
raise IOError, "
|
|
28
|
+
raise IOError, "Configuration readback failed" unless capture.success?
|
|
27
29
|
observation = Vxlan::Config.new.parse(capture.body, complete: true)
|
|
28
|
-
raise ArgumentError, "
|
|
29
|
-
|
|
30
|
+
raise ArgumentError, "Configuration evidence is incomplete" unless observation.complete? && observation.records.one?
|
|
31
|
+
CtxRunner::Document.call(observation.records.first)
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
# 每次只发一条命令;失败立即交还宿主,原始输出不进入终端和日志报告。
|
|
33
35
|
def write(command)
|
|
34
36
|
result = @native.execute_command(command.strip, output_sensitive: true)
|
|
35
|
-
raise IOError, "
|
|
37
|
+
raise IOError, "Device command is unconfirmed" unless result.success? && result.steps.one?
|
|
36
38
|
true
|
|
37
39
|
end
|
|
38
40
|
end
|
|
@@ -50,44 +52,44 @@ module Vxlan
|
|
|
50
52
|
@store, @execution = nil, nil
|
|
51
53
|
options = { directory: File.join(Dir.pwd, "vxlan-executions"), port: 22 }
|
|
52
54
|
parser = OptionParser.new do |p|
|
|
53
|
-
p.banner = "
|
|
54
|
-
p.on("--plan FILE", "
|
|
55
|
-
p.on("--item ID", "
|
|
56
|
-
p.on("--env-file FILE", "
|
|
57
|
-
p.on("--directory DIR", "
|
|
58
|
-
p.on("--port N", Integer, "SSH
|
|
59
|
-
p.on("-h", "--help", "
|
|
55
|
+
p.banner = "Usage: vxlan apply --plan FILE --item ID [--env-file FILE] (requires explicit confirmation in an interactive terminal)"
|
|
56
|
+
p.on("--plan FILE", "Batch plan.json; requires a SHA256 checksum file in the same directory") { |value| options[:plan] = value }
|
|
57
|
+
p.on("--item ID", "Explicitly select one change from the batch plan; bulk execution is prohibited") { |value| options[:item] = value }
|
|
58
|
+
p.on("--env-file FILE", "Explicit credentials file; process environment takes precedence") { |value| options[:env_file] = value }
|
|
59
|
+
p.on("--directory DIR", "Private execution log directory") { |value| options[:directory] = value }
|
|
60
|
+
p.on("--port N", Integer, "SSH port, default 22") { |value| options[:port] = value }
|
|
61
|
+
p.on("-h", "--help", "Show help without connecting to devices") { options[:help] = true }
|
|
60
62
|
end
|
|
61
63
|
rest = parser.parse(argv.dup)
|
|
62
64
|
return @out.puts(parser) || 0 if options[:help]
|
|
63
65
|
terminal = CtxCli::Terminal.new(input: @input, out: @out)
|
|
64
66
|
terminal.require_tty!(@error)
|
|
65
|
-
raise ArgumentError, "
|
|
66
|
-
body =
|
|
67
|
+
raise ArgumentError, "A single plan is required" unless rest.empty? && options[:plan] && options[:port].between?(1, 65_535)
|
|
68
|
+
body = CtxCli::Input.read(path: options[:plan])
|
|
67
69
|
hash = Digest::SHA256.hexdigest(body)
|
|
68
70
|
plan = CtxParser::Immutable.copy(select_plan(JSON.parse(body), options, hash))
|
|
69
71
|
change = validate_plan(plan)
|
|
70
72
|
host = plan.fetch("device").fetch("host")
|
|
71
|
-
@out.puts "
|
|
72
|
-
@out.puts "
|
|
73
|
-
@out.puts Array(plan["prerequisites"]).map { |value| "
|
|
74
|
-
@out.puts "
|
|
73
|
+
@out.puts "Target: #{plan['device']['name']}(#{host}); #{plan['scope']}; #{plan['title']}"
|
|
74
|
+
@out.puts "Impact: #{plan['impact']}"
|
|
75
|
+
@out.puts Array(plan["prerequisites"]).map { |value| "Prerequisite: #{value}" }
|
|
76
|
+
@out.puts "Plan SHA256: #{hash}\nProposed commands:\n#{change.commands.join("\n")}\nConditional rollback (not executed automatically):\n#{change.rollback.join("\n")}"
|
|
75
77
|
token = "APPLY #{host} #{hash[0, 12]} #{options[:item]}"
|
|
76
|
-
confirmed = terminal.confirm(token: token, prompt: "
|
|
77
|
-
return @out.puts("
|
|
78
|
+
confirmed = terminal.confirm(token: token, prompt: "After confirming the service policy and maintenance window, enter #{token}; any other input cancels: ")
|
|
79
|
+
return @out.puts("Cancelled; no device connection opened.") || 1 unless confirmed
|
|
78
80
|
settings = credentials(options)
|
|
79
81
|
@store = Report.new(options[:directory])
|
|
80
82
|
@execution = CtxRunner::Execution.new(plan: plan, change: change, journal: @store, plan_sha256: hash, item: options[:item])
|
|
81
83
|
@execution.run { |operation| with_session(plan["device"], settings, &operation) }
|
|
82
|
-
@out.puts "
|
|
84
|
+
@out.puts "Configuration readback confirmed; service acceptance is still required. Configuration was not saved to startup configuration. Log: #{@store.directory}/execution.json"
|
|
83
85
|
0
|
|
84
86
|
rescue Interrupt
|
|
85
87
|
report_journal_failure
|
|
86
|
-
@error.puts "
|
|
88
|
+
@error.puts "Operation interrupted; #{@execution&.started? ? 'Device effects may be incomplete; read back and verify before proceeding. Do not replay commands.' : 'No service configuration was sent.'}"
|
|
87
89
|
130
|
|
88
90
|
rescue StandardError, LoadError => error
|
|
89
91
|
report_journal_failure
|
|
90
|
-
@error.puts "
|
|
92
|
+
@error.puts "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.'}"
|
|
91
93
|
2
|
|
92
94
|
end
|
|
93
95
|
|
|
@@ -98,20 +100,20 @@ module Vxlan
|
|
|
98
100
|
id = options[:item]
|
|
99
101
|
unless document.is_a?(Hash) && document["schema_version"] == 2 && document["platform"] == "cisco_nxos" && document["changes"].is_a?(Hash) &&
|
|
100
102
|
id.is_a?(String) && id.match?(/\A[A-Za-z0-9_.-]+\z/) && document["changes"].key?(id)
|
|
101
|
-
raise ArgumentError, "
|
|
103
|
+
raise ArgumentError, "A batch plan and valid --item are required"
|
|
102
104
|
end
|
|
103
|
-
raise ArgumentError, "
|
|
105
|
+
raise ArgumentError, "Plan checksum verification failed" unless CtxCli::Input.read(path: "#{options[:plan]}.sha256", max_bytes: 128).strip == hash
|
|
104
106
|
document["changes"].fetch(id)
|
|
105
107
|
end
|
|
106
108
|
|
|
107
109
|
# 不信任文件中的命令,重新从有限动作渲染并逐字核对,拒绝额外命令。
|
|
108
110
|
def validate_plan(plan)
|
|
109
|
-
raise ArgumentError, "
|
|
111
|
+
raise ArgumentError, "Plan type mismatch" unless plan.is_a?(Hash) && plan["schema_version"] == 1 && plan["platform"] == "cisco_nxos"
|
|
110
112
|
host = plan.fetch("device").fetch("host")
|
|
111
|
-
raise ArgumentError, "
|
|
112
|
-
raise ArgumentError, "
|
|
113
|
+
raise ArgumentError, "Invalid target address" unless host.is_a?(String) && !host.include?("/") && IPAddr.new(host).to_s == host
|
|
114
|
+
raise ArgumentError, "Invalid configuration fingerprint" unless plan["expected_config_sha256"].is_a?(String) && plan["expected_config_sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
113
115
|
change = ChangePlan.build(plan.fetch("change"))
|
|
114
|
-
raise ArgumentError, "
|
|
116
|
+
raise ArgumentError, "Plan commands were modified" unless change.commands == plan["commands"] && change.rollback == plan["rollback"]
|
|
115
117
|
change
|
|
116
118
|
end
|
|
117
119
|
|
|
@@ -120,7 +122,7 @@ module Vxlan
|
|
|
120
122
|
require "net/connector/config"
|
|
121
123
|
env = Net::Connector::Config.new(name: "vxlan", env: @env, env_file: options[:env_file], **@discovery).load_environment.environment
|
|
122
124
|
username = env["NC_CISCO_NXOS_USERNAME"] || env["NC_DEVICE_USERNAME"]
|
|
123
|
-
raise ArgumentError, "
|
|
125
|
+
raise ArgumentError, "Device username is missing" unless username.is_a?(String) && !username.strip.empty?
|
|
124
126
|
{ username: username, password: env["NC_CISCO_NXOS_PASSWORD"] || env["NC_DEVICE_PASSWORD"], enable_password: env["NC_ENABLE_PASSWORD"],
|
|
125
127
|
port: options[:port], host_key_policy: "strict", command_timeout: 60, login_timeout: 15 }
|
|
126
128
|
end
|
|
@@ -137,7 +139,7 @@ module Vxlan
|
|
|
137
139
|
# 公共执行器保存失败状态;CLI 只呈现持久性尚未确认的诊断。
|
|
138
140
|
def report_journal_failure
|
|
139
141
|
return unless @execution&.journal_failed?
|
|
140
|
-
@error.puts "
|
|
142
|
+
@error.puts "Execution log finalization is unconfirmed; existing checkpoints retained. Do not replay configuration based on this state."
|
|
141
143
|
rescue StandardError, Interrupt
|
|
142
144
|
nil
|
|
143
145
|
end
|
data/lib/vxlan/cli/collect.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "ctx_cli/collection"
|
|
3
4
|
require "vxlan"
|
|
4
5
|
require "ctx_runner/batch"
|
|
5
|
-
require "ctx_cli/collection"
|
|
6
6
|
require_relative "options"
|
|
7
7
|
require_relative "report"
|
|
8
8
|
require_relative "connector"
|
|
@@ -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 来源二选一;所有重复地址在连接前拒绝。
|
|
@@ -68,18 +68,26 @@ module Vxlan
|
|
|
68
68
|
end
|
|
69
69
|
unless @executor
|
|
70
70
|
require "net/connector/resources"
|
|
71
|
-
|
|
71
|
+
begin
|
|
72
|
+
Net::Connector::Resources.prepare!(parallel: [ devices.size, options.values.fetch("parallel") ].min)
|
|
73
|
+
rescue ArgumentError
|
|
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
|
+
end
|
|
72
76
|
end
|
|
73
77
|
credentials
|
|
74
78
|
end
|
|
75
79
|
|
|
76
|
-
def report(options)
|
|
80
|
+
def report(options)
|
|
81
|
+
Report.new(options.values.fetch("directory"), scripts: options.flags[:scripts] == true)
|
|
82
|
+
rescue ArgumentError
|
|
83
|
+
raise CtxCli::Collection::Error, "Invalid report directory; use a real private directory with mode 0700 and check its path.", cause: nil
|
|
84
|
+
end
|
|
77
85
|
def after_collect(report, options) = report.review_collected_fabric(options.fabric_policy)
|
|
78
86
|
|
|
79
87
|
# 缺失用户名时不尝试连接;密码允许省略以支持明确的 SSH key/agent。
|
|
80
88
|
def credentials!(options)
|
|
81
89
|
username = secret(options, "NC_CISCO_NXOS_USERNAME") || secret(options, "NC_DEVICE_USERNAME")
|
|
82
|
-
raise
|
|
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
|
|
83
91
|
options.values.fetch("ssh").transform_keys(&:to_sym).merge(username: username,
|
|
84
92
|
password: secret(options, "NC_CISCO_NXOS_PASSWORD") || secret(options, "NC_DEVICE_PASSWORD"), enable_password: secret(options, "NC_ENABLE_PASSWORD"))
|
|
85
93
|
end
|
data/lib/vxlan/cli/options.rb
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "ctx_cli/collection"
|
|
4
|
+
require "ctx_cli/input"
|
|
3
5
|
require "optparse"
|
|
4
6
|
require "ctx_cli/options"
|
|
5
7
|
require "ctx_cli/terminal"
|
|
6
|
-
require "ctx_cli/
|
|
8
|
+
require "ctx_cli/initializer"
|
|
9
|
+
gem "net-connector", "~> 0.18.0"
|
|
7
10
|
require "net/connector/inventory/filters"
|
|
8
11
|
require "yaml"
|
|
9
12
|
require "ipaddr"
|
|
@@ -26,7 +29,7 @@ module Vxlan
|
|
|
26
29
|
@explicit_config, @explicit_env_file = config, env_file
|
|
27
30
|
@overrides, @flags = {}, {}
|
|
28
31
|
@parser = parser
|
|
29
|
-
@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? }
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
# 帮助和范例只打印文本,不读取文件或加载可选网络依赖。
|
|
@@ -34,12 +37,12 @@ module Vxlan
|
|
|
34
37
|
if flags[:help] || flags[:help_all]
|
|
35
38
|
@out.puts @parser
|
|
36
39
|
if flags[:help_all]
|
|
37
|
-
@out.puts "
|
|
38
|
-
@out.puts "
|
|
39
|
-
@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}"
|
|
40
43
|
end
|
|
41
44
|
elsif flags[:examples]
|
|
42
|
-
@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"
|
|
43
46
|
else
|
|
44
47
|
return false
|
|
45
48
|
end
|
|
@@ -49,7 +52,7 @@ module Vxlan
|
|
|
49
52
|
# 自动化拒绝强制问答;帮助分支无需检查终端或消费输入。
|
|
50
53
|
def validate_interaction!(input:, error:)
|
|
51
54
|
if flags[:interactive] && !CtxCli::Terminal.tty?(input, error)
|
|
52
|
-
raise
|
|
55
|
+
raise CtxCli::Collection::Error, "--interactive requires an interactive terminal; use --init --non-interactive for automation"
|
|
53
56
|
end
|
|
54
57
|
end
|
|
55
58
|
|
|
@@ -64,7 +67,7 @@ module Vxlan
|
|
|
64
67
|
@environment = @files.environment
|
|
65
68
|
@config, @env_file = @files.config, @files.env_file
|
|
66
69
|
rescue LoadError
|
|
67
|
-
raise LoadError, "
|
|
70
|
+
raise LoadError, "Collection configuration requires net-connector >= 0.18.0; reading .env also requires dotenv", cause: nil
|
|
68
71
|
end
|
|
69
72
|
|
|
70
73
|
# 公共与专用文件先合并,进程值和 CLI 最后覆盖;数组不追加设备。
|
|
@@ -72,7 +75,7 @@ module Vxlan
|
|
|
72
75
|
@files.load
|
|
73
76
|
@config, @env_file = @files.config, @files.env_file
|
|
74
77
|
data = Net::Connector::Config.merge(@files.data)
|
|
75
|
-
raise ArgumentError, "YAML
|
|
78
|
+
raise ArgumentError, "YAML accepts only public configuration fields" unless (data.keys - FIELDS).empty?
|
|
76
79
|
specific = @files.specific
|
|
77
80
|
if specific["devices"].is_a?(Array) && specific["devices"].any? && (specific.keys & %w[netdisco inventory netdisco_config]).empty?
|
|
78
81
|
data = data.except("netdisco", "inventory")
|
|
@@ -90,14 +93,14 @@ module Vxlan
|
|
|
90
93
|
netdisco_settings if dynamic_inventory?
|
|
91
94
|
@fabric_policy = nil
|
|
92
95
|
if values["fabric_policy"]
|
|
93
|
-
raise ArgumentError, "fabric_policy
|
|
94
|
-
policy = YAML.safe_load(
|
|
96
|
+
raise ArgumentError, "fabric_policy must be a path" unless values["fabric_policy"].is_a?(String)
|
|
97
|
+
policy = YAML.safe_load(CtxCli::Input.read(path: values["fabric_policy"]), aliases: false)
|
|
95
98
|
Vxlan::Fabric::Scope.new(policy)
|
|
96
99
|
@fabric_policy = CtxParser::Immutable.copy(policy)
|
|
97
100
|
end
|
|
98
101
|
self
|
|
99
102
|
rescue Psych::Exception
|
|
100
|
-
raise ArgumentError, "YAML
|
|
103
|
+
raise ArgumentError, "Invalid YAML or disallowed types", cause: nil
|
|
101
104
|
end
|
|
102
105
|
|
|
103
106
|
# 对外只列来源路径和字段归属,不携带环境文件的值。
|
|
@@ -154,10 +157,10 @@ module Vxlan
|
|
|
154
157
|
template = template.lines("\n").select { |line| line.start_with?("#") }.join("") +
|
|
155
158
|
Net::Connector::Config.dump(document, name: "vxlan", path: config)
|
|
156
159
|
{ config => template, env_file => File.read(File.join(__dir__, "templates/env.example")) }.each do |path, body|
|
|
157
|
-
status = CtxCli::
|
|
158
|
-
@out.puts "#{status == :created ? '
|
|
160
|
+
status = CtxCli::Initializer.new(prefix: ".vxlan-init-").create(path: path, contents: body)
|
|
161
|
+
@out.puts "#{status == :created ? 'created' : 'preserved'}: #{path}"
|
|
159
162
|
end
|
|
160
|
-
@out.puts "
|
|
163
|
+
@out.puts "Check configuration and credentials, then run again; inventory was not read and no devices were connected."
|
|
161
164
|
end
|
|
162
165
|
|
|
163
166
|
# 默认仅在真实终端提供初始化选择;自动化从不消费 stdin。
|
|
@@ -167,20 +170,20 @@ module Vxlan
|
|
|
167
170
|
return false unless @files.missing?
|
|
168
171
|
terminal = CtxCli::Terminal.new(input: input, out: error)
|
|
169
172
|
if terminal.tty? && flags[:interactive] != false
|
|
170
|
-
answer = terminal.question("
|
|
173
|
+
answer = terminal.question("Configuration does not exist: #{config}; create configuration and environment templates? [y/N] ")
|
|
171
174
|
if answer&.match?(/\Ay(?:es)?\z/i)
|
|
172
175
|
initialize_files
|
|
173
176
|
return true
|
|
174
177
|
end
|
|
175
178
|
end
|
|
176
|
-
raise ArgumentError, "
|
|
179
|
+
raise ArgumentError, "Configuration does not exist: #{config}; run --init and fill in devices, or select configuration with --config"
|
|
177
180
|
end
|
|
178
181
|
|
|
179
182
|
# 公开配置输出与实际清单复用身份预检,不读取远端清单。
|
|
180
183
|
def normalize_devices(rows)
|
|
181
|
-
raise ArgumentError, "devices
|
|
184
|
+
raise ArgumentError, "devices must be an array" unless rows.is_a?(Array)
|
|
182
185
|
targets = rows.map { |row| normalize_device(row) }
|
|
183
|
-
raise ArgumentError, "
|
|
186
|
+
raise ArgumentError, "Device inventory contains duplicate management addresses" unless targets.map { |row| row.fetch("host") }.uniq.size == targets.size
|
|
184
187
|
targets
|
|
185
188
|
end
|
|
186
189
|
|
|
@@ -209,20 +212,20 @@ module Vxlan
|
|
|
209
212
|
# 设备身份只接受公开字段,拒绝隐藏在 YAML 中的密码或连接参数。
|
|
210
213
|
def normalize_device(row)
|
|
211
214
|
unless row.is_a?(Hash) && (row.keys - %w[host name model]).empty? && row["host"].is_a?(String) && !row["host"].include?("/")
|
|
212
|
-
raise ArgumentError, "devices
|
|
215
|
+
raise ArgumentError, "devices accepts only host, name and model; host must be a single IP address"
|
|
213
216
|
end
|
|
214
217
|
host = IPAddr.new(row.fetch("host")).to_s
|
|
215
|
-
%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?) }
|
|
216
219
|
{ "host" => host, "name" => row["name"] || host, "model" => row["model"] }.freeze
|
|
217
220
|
rescue IPAddr::Error
|
|
218
|
-
raise ArgumentError, "
|
|
221
|
+
raise ArgumentError, "Invalid device host", cause: nil
|
|
219
222
|
end
|
|
220
223
|
|
|
221
224
|
# 显式声明短参数与长参数;不提供难以发现的兼容缩写。
|
|
222
225
|
def parser
|
|
223
226
|
OptionParser.new do |p|
|
|
224
|
-
p.banner = "
|
|
225
|
-
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|
|
|
226
229
|
case key
|
|
227
230
|
when :config then @explicit_config = File.expand_path(value, Dir.pwd)
|
|
228
231
|
when :env_file then @explicit_env_file = File.expand_path(value, Dir.pwd)
|
|
@@ -230,9 +233,9 @@ module Vxlan
|
|
|
230
233
|
when :parallel then @overrides["parallel"] = value
|
|
231
234
|
end
|
|
232
235
|
end
|
|
233
|
-
p.on("--fabric-policy FILE", "
|
|
234
|
-
p.on("--fabric NAME", "
|
|
235
|
-
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) }
|
|
236
239
|
Net::Connector::Inventory::Filters.add_options(p) do |key, value|
|
|
237
240
|
if value.is_a?(Array)
|
|
238
241
|
(@overrides[key] ||= []).concat(value)
|
|
@@ -241,13 +244,13 @@ module Vxlan
|
|
|
241
244
|
end
|
|
242
245
|
end
|
|
243
246
|
CtxCli::Options.initialization(p, flags)
|
|
244
|
-
p.on("--plan", "--inventory-only", "
|
|
245
|
-
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 }
|
|
246
249
|
CtxCli::Options.output(p, flags)
|
|
247
|
-
p.on("--[no-]scripts", "
|
|
250
|
+
p.on("--[no-]scripts", "Optionally export configuration suggestions; diagnostics only by default") { |value| flags[:scripts] = value }
|
|
248
251
|
CtxCli::Options.information(p, flags)
|
|
249
|
-
p.separator "
|
|
250
|
-
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."
|
|
251
254
|
end
|
|
252
255
|
end
|
|
253
256
|
|
|
@@ -256,40 +259,40 @@ module Vxlan
|
|
|
256
259
|
|
|
257
260
|
# 先验证整批策略,避免在前面设备连接后才发现后面的非法配置。
|
|
258
261
|
def validate!
|
|
259
|
-
raise ArgumentError, "parallel
|
|
260
|
-
%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? }
|
|
261
264
|
dimensions = values["dimensions"]
|
|
262
265
|
unless dimensions.is_a?(Array) && dimensions.any? && dimensions.all?(String) && dimensions.uniq == dimensions && (dimensions - Vxlan::Collector::COMMANDS.keys.map(&:to_s)).empty?
|
|
263
|
-
raise ArgumentError, "dimensions
|
|
266
|
+
raise ArgumentError, "dimensions contains unknown, duplicate or empty dimensions"
|
|
264
267
|
end
|
|
265
268
|
values["devices"] = normalize_devices(values["devices"])
|
|
266
269
|
inline = %w[netdisco inventory].any? { |key| values.key?(key) }
|
|
267
270
|
%w[netdisco inventory].each do |key|
|
|
268
|
-
raise ArgumentError, "#{key}
|
|
271
|
+
raise ArgumentError, "#{key} must be a mapping" if values.key?(key) && !values[key].is_a?(Hash)
|
|
269
272
|
end
|
|
270
273
|
if inline && (!values["devices"].empty? || values["netdisco_config"])
|
|
271
|
-
raise ArgumentError, "
|
|
274
|
+
raise ArgumentError, "Static devices, inline Netdisco and external netdisco_config are mutually exclusive"
|
|
272
275
|
end
|
|
273
276
|
if values["netdisco_config"]
|
|
274
|
-
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?
|
|
275
278
|
values["netdisco_config"] = File.expand_path(values["netdisco_config"], Dir.pwd)
|
|
276
279
|
end
|
|
277
280
|
Net::Connector::Inventory::Filters::LISTS.each_key { |key| values[key] = values.fetch(key, []) }
|
|
278
281
|
Net::Connector::Inventory::Filters.selection(values)
|
|
279
282
|
ssh = values["ssh"]
|
|
280
|
-
raise ArgumentError, "ssh
|
|
283
|
+
raise ArgumentError, "ssh contains unknown or sensitive fields" unless ssh.is_a?(Hash) && (ssh.keys - SSH_FIELDS).empty?
|
|
281
284
|
ssh = SSH_DEFAULTS.merge(ssh)
|
|
282
|
-
raise ArgumentError, "host_key_policy
|
|
283
|
-
%w[command_timeout login_timeout].each { |key| raise ArgumentError, "#{key}
|
|
284
|
-
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"]))
|
|
285
288
|
if ssh.key?("known_hosts")
|
|
286
|
-
raise ArgumentError, "known_hosts
|
|
289
|
+
raise ArgumentError, "known_hosts must be a path" unless ssh["known_hosts"].is_a?(String) && !ssh["known_hosts"].empty?
|
|
287
290
|
ssh["known_hosts"] = File.expand_path(ssh["known_hosts"], Dir.pwd)
|
|
288
291
|
end
|
|
289
292
|
values["ssh"] = ssh
|
|
290
293
|
values["directory"] = File.expand_path(values["directory"], Dir.pwd)
|
|
291
294
|
rescue IPAddr::Error
|
|
292
|
-
raise ArgumentError, "hosts
|
|
295
|
+
raise ArgumentError, "hosts must contain IPs or CIDRs; addresses are not expanded and subnets are not scanned", cause: nil
|
|
293
296
|
end
|
|
294
297
|
end
|
|
295
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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "time"
|
|
5
5
|
require "ctx_runner/report"
|
|
6
|
+
require "ctx_runner/document"
|
|
6
7
|
require_relative "review/report"
|
|
7
8
|
require_relative "review/fabric"
|
|
8
9
|
|
|
@@ -36,9 +37,9 @@ module Vxlan
|
|
|
36
37
|
name = self.class.device_basename(device)
|
|
37
38
|
# 保存状态属于批次宿主;不能让正在发布的文件预先声明自身已保存。
|
|
38
39
|
body = result.reject { |key, _| key == :report_saved }.merge(snapshot: snapshot && snapshot.to_h)
|
|
39
|
-
document =
|
|
40
|
+
document = CtxRunner::Document.call(body)
|
|
40
41
|
advice = report_advice(Recommendations.new(document).call)
|
|
41
|
-
document
|
|
42
|
+
document = document.merge("recommendations" => advice)
|
|
42
43
|
json = JSON.pretty_generate(document) + "\n"
|
|
43
44
|
prepare_directory(File.join(directory, "evidence"))
|
|
44
45
|
publish("evidence/#{name}.json", json)
|
|
@@ -49,19 +50,19 @@ module Vxlan
|
|
|
49
50
|
def finish(results, interrupted: false, requested: [], report_error: nil, execution_error: nil, output_error: nil)
|
|
50
51
|
@report.finish(results, interrupted: interrupted, requested: requested, report_error: report_error,
|
|
51
52
|
execution_error: execution_error, output_error: output_error, review: @review_summary) do |summary|
|
|
52
|
-
pending = summary.fetch(
|
|
53
|
-
text = [ "VXLAN
|
|
54
|
-
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."
|
|
55
56
|
text << @review_text if @review_text
|
|
56
|
-
text << "
|
|
57
|
-
text << "
|
|
58
|
-
text << "
|
|
59
|
-
text << "
|
|
60
|
-
|
|
61
|
-
saved = r[
|
|
62
|
-
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']}"
|
|
63
64
|
end
|
|
64
|
-
pending.each { |device| text << "#{device['host']}\t#{device['name']}\
|
|
65
|
+
pending.each { |device| text << "#{device['host']}\t#{device['name']}\tnot collected" }
|
|
65
66
|
text.join("\n") + "\n"
|
|
66
67
|
end
|
|
67
68
|
end
|
|
@@ -71,7 +72,6 @@ module Vxlan
|
|
|
71
72
|
# 保持领域呈现接口,事务与恢复状态仅在公共报告中实现。
|
|
72
73
|
def prepare_directory(path) = @report.prepare_directory(path)
|
|
73
74
|
def publish(relative, body) = @report.publish(relative, body)
|
|
74
|
-
def serializable(value) = @report.serializable(value)
|
|
75
75
|
def checkpoint(relative, value) = @report.checkpoint(relative, value)
|
|
76
76
|
def finalize(relative, summary, checkpoint:) = @report.finalize(relative, summary, checkpoint: checkpoint)
|
|
77
77
|
end
|