vxlan 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README-zh.md +32 -4
  4. data/README.md +32 -4
  5. data/bin/README.md +203 -0
  6. data/bin/vxlan +8 -0
  7. data/docs/architecture-zh.md +62 -60
  8. data/docs/evidence-contracts.md +54 -0
  9. data/docs/remediation.md +84 -0
  10. data/docs/review-2026-10-19.md +31 -0
  11. data/docs/superpowers/plans/2026-10-19-command-line.md +69 -0
  12. data/docs/superpowers/plans/2026-10-19-connector-integration.md +83 -0
  13. data/docs/superpowers/plans/2026-10-19-evidence-contracts.md +64 -0
  14. data/docs/superpowers/plans/2026-10-19-vxlan-examples.md +47 -0
  15. data/docs/superpowers/plans/2026-10-20-fabric-consistency.md +28 -0
  16. data/docs/superpowers/specs/2026-10-19-command-line-design.md +78 -0
  17. data/docs/superpowers/specs/2026-10-19-connector-integration-design.md +100 -0
  18. data/docs/superpowers/specs/2026-10-19-evidence-contracts.md +11 -0
  19. data/examples/README.md +20 -0
  20. data/examples/collect.rb +8 -0
  21. data/examples/parse.rb +5 -0
  22. data/lib/vxlan/acl_policy.rb +50 -0
  23. data/lib/vxlan/change_plan/svi_acl.rb +76 -0
  24. data/lib/vxlan/change_plan/svi_gateway.rb +36 -0
  25. data/lib/vxlan/change_plan/vlan_vni.rb +40 -0
  26. data/lib/vxlan/change_plan.rb +131 -0
  27. data/lib/vxlan/cisco_nxos/configuration_state.rb +1 -4
  28. data/lib/vxlan/cisco_nxos/evpn_l2route.rb +2 -2
  29. data/lib/vxlan/cisco_nxos/parser.rb +2 -2
  30. data/lib/vxlan/cisco_nxos/parsers/show_bgp_l2vpn_evpn_summary.rb +67 -13
  31. data/lib/vxlan/cisco_nxos/parsers/show_cdp_neighbors_detail.rb +32 -8
  32. data/lib/vxlan/cisco_nxos/parsers/show_mac_address_table.rb +1 -1
  33. data/lib/vxlan/cisco_nxos/parsers/show_nve_peers.rb +5 -4
  34. data/lib/vxlan/cisco_nxos/parsers/show_nve_vni.rb +9 -1
  35. data/lib/vxlan/cisco_nxos/parsers/show_running_config.rb +40 -8
  36. data/lib/vxlan/cisco_nxos/parsers/show_vpc_brief.rb +11 -5
  37. data/lib/vxlan/cisco_nxos/syntax/policies.rb +16 -0
  38. data/lib/vxlan/cisco_nxos/syntax/routing.rb +16 -6
  39. data/lib/vxlan/cli/apply.rb +172 -0
  40. data/lib/vxlan/cli/collect.rb +210 -0
  41. data/lib/vxlan/cli/connector.rb +23 -0
  42. data/lib/vxlan/cli/options.rb +311 -0
  43. data/lib/vxlan/cli/parse.rb +58 -0
  44. data/lib/vxlan/cli/report.rb +223 -0
  45. data/lib/vxlan/cli/review/fabric.rb +102 -0
  46. data/lib/vxlan/cli/review/report.rb +165 -0
  47. data/lib/vxlan/cli/review.rb +125 -0
  48. data/lib/vxlan/cli/templates/env.example +12 -0
  49. data/lib/vxlan/cli/templates/vxlan.yml +32 -0
  50. data/lib/vxlan/cli.rb +41 -0
  51. data/lib/vxlan/collector.rb +59 -28
  52. data/lib/vxlan/config/evidence.rb +18 -0
  53. data/lib/vxlan/config/review.rb +56 -0
  54. data/lib/vxlan/{configuration.rb → config.rb} +1 -1
  55. data/lib/vxlan/fabric/review.rb +324 -0
  56. data/lib/vxlan/fabric/scope.rb +43 -0
  57. data/lib/vxlan/observation.rb +3 -0
  58. data/lib/vxlan/recommendations.rb +193 -0
  59. data/lib/vxlan/service/catalog.rb +77 -0
  60. data/lib/vxlan/service/mapping.rb +53 -0
  61. data/lib/vxlan/snapshot.rb +3 -0
  62. data/lib/vxlan/subnet_index.rb +88 -0
  63. data/lib/vxlan/version.rb +1 -1
  64. data/lib/vxlan.rb +4 -2
  65. metadata +137 -7
  66. data/lib/vxlan/cisco_nxos/configuration.rb +0 -10
  67. data/lib/vxlan/configuration_review.rb +0 -54
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "yaml"
5
+ require "ipaddr"
6
+ require "fileutils"
7
+ require "tempfile"
8
+
9
+ module Vxlan
10
+ module CLI
11
+ # 采集入口的公开参数;凭据始终留在环境中,不传入解析库或 YAML。
12
+ class Options
13
+ FIELDS = %w[fabric fabric_policy devices netdisco inventory netdisco_config parallel directory dimensions ssh hosts exclude_hosts names exclude_names].freeze
14
+ SSH_FIELDS = %w[port command_timeout login_timeout host_key_policy known_hosts].freeze
15
+ SSH_DEFAULTS = { "host_key_policy" => "strict", "command_timeout" => 60, "login_timeout" => 15 }.freeze
16
+ attr_reader :values, :config, :env_file, :flags, :fabric_policy, :environment
17
+
18
+ PATH_FIELDS = %w[directory fabric_policy netdisco_config ssh.known_hosts netdisco.cache_directory netdisco.db_sslrootcert].freeze
19
+
20
+ # 文件发现由共享加载器负责;这里只保存调用入口和领域参数。
21
+ def initialize(argv, env:, out:, config: nil, env_file: nil, directory: File.join(Dir.pwd, "vxlans"), discovery: {}, command: "vxlan collect")
22
+ @process_environment, @out = env.to_h.dup, out
23
+ @directory, @command, @discovery = directory, command, discovery
24
+ @explicit_config, @explicit_env_file = config, env_file
25
+ @overrides, @flags = {}, {}
26
+ @parser = parser
27
+ @parser.parse!(argv.dup).tap { |rest| raise ArgumentError, "不接受位置参数" unless rest.empty? }
28
+ end
29
+
30
+ # 帮助和范例只打印文本,不读取文件或加载可选网络依赖。
31
+ def informational?
32
+ if flags[:help] || flags[:help_all]
33
+ @out.puts @parser
34
+ if flags[:help_all]
35
+ @out.puts "环境默认:VXLAN_CONFIG、VXLAN_FABRIC、VXLAN_PARALLEL(或 NC_PARALLEL)、VXLAN_DIRECTORY。"
36
+ @out.puts "凭据:NC_DEVICE_USERNAME / NC_DEVICE_PASSWORD,或 NC_CISCO_NXOS_USERNAME / NC_CISCO_NXOS_PASSWORD;可选 NC_ENABLE_PASSWORD。"
37
+ @out.puts "优先级:CLI > 进程环境 > YAML > .env > 内置默认值。YAML/.env 路径基于来源目录;CLI 路径基于启动目录。默认报告:#{@directory}"
38
+ end
39
+ elsif flags[:examples]
40
+ @out.puts "常用示例(地址须替换为实际清单地址):\n 初始化缺失文件后退出:\n #{@command} --init --non-interactive\n 预览筛选清单:\n #{@command} --host 192.0.2.10 --plan\n 按名称选择设备:\n #{@command} --name leaf --exclude-name spare -p 4\n 自动化采集摘要:\n #{@command} --dimensions configuration,nve_peers --json --no-progress"
41
+ else
42
+ return false
43
+ end
44
+ true
45
+ end
46
+
47
+ # 自动化拒绝强制问答;帮助分支无需检查终端或消费输入。
48
+ def validate_interaction!(input:, error:)
49
+ if flags[:interactive] && !(input.tty? && error.tty?)
50
+ raise ArgumentError, "--interactive 需要交互终端;自动化请使用 --init --non-interactive"
51
+ end
52
+ end
53
+
54
+ # 只在采集/初始化时加载共享文件服务,不改写进程环境。
55
+ def load_environment
56
+ require "net/connector/config"
57
+ @files = Net::Connector::Config.new(name: "vxlan", env: @process_environment,
58
+ config: @explicit_config, env_file: @explicit_env_file, path_fields: PATH_FIELDS,
59
+ env_path_keys: %w[VXLAN_DIRECTORY NC_KNOWN_HOSTS ND_CACHE_DIRECTORY ND_DB_SSLROOTCERT],
60
+ path_literals: { "netdisco.db_sslrootcert" => [ "system" ], "ND_DB_SSLROOTCERT" => [ "system" ] }, **@discovery)
61
+ @files.load_environment(allow_missing: flags[:init])
62
+ @environment = @files.environment
63
+ @config, @env_file = @files.config, @files.env_file
64
+ rescue LoadError
65
+ raise LoadError, "采集配置需要 net-connector >= 0.16.0;读取 .env 还需要 dotenv", cause: nil
66
+ end
67
+
68
+ # 公共与专用文件先合并,进程值和 CLI 最后覆盖;数组不追加设备。
69
+ def load
70
+ @files.load
71
+ @config, @env_file = @files.config, @files.env_file
72
+ data = Net::Connector::Config.merge(@files.data)
73
+ raise ArgumentError, "YAML 只接受公开配置字段" unless (data.keys - FIELDS).empty?
74
+ specific = @files.specific
75
+ if specific["devices"].is_a?(Array) && specific["devices"].any? && (specific.keys & %w[netdisco inventory netdisco_config]).empty?
76
+ data = data.except("netdisco", "inventory")
77
+ elsif specific["netdisco_config"] && (specific.keys & %w[netdisco inventory]).empty?
78
+ data = data.except("netdisco", "inventory")
79
+ end
80
+ defaults = { "fabric" => "default", "devices" => [], "parallel" => 4, "directory" => @directory,
81
+ "dimensions" => Vxlan::Collector::COMMANDS.keys.map(&:to_s), "ssh" => SSH_DEFAULTS }
82
+ dynamic = (data.keys & %w[netdisco inventory]).any?
83
+ process = environment_values(@files.process_environment, dynamic: dynamic)
84
+ file_defaults = environment_values(@files.file_environment, dynamic: dynamic,
85
+ higher: Net::Connector::Config.merge(data, process, @overrides))
86
+ @values = Net::Connector::Config.merge(defaults, file_defaults, data, process, @overrides)
87
+ validate!
88
+ netdisco_settings if dynamic_inventory?
89
+ @fabric_policy = nil
90
+ if values["fabric_policy"]
91
+ raise ArgumentError, "fabric_policy 须为路径" unless values["fabric_policy"].is_a?(String)
92
+ policy = YAML.safe_load(File.read(values["fabric_policy"]), aliases: false)
93
+ Vxlan::Fabric::Scope.new(policy)
94
+ @fabric_policy = CtxParser::Immutable.copy(policy)
95
+ end
96
+ self
97
+ rescue Psych::Exception
98
+ raise ArgumentError, "YAML 无效或包含不允许的类型", cause: nil
99
+ end
100
+
101
+ # 对外只列来源路径和字段归属,不携带环境文件的值。
102
+ def sources = @files.sources
103
+
104
+ # 来源选择与默认参数分开;共同 Netdisco 配置不会改写显式静态清单。
105
+ def dynamic_inventory? = values["netdisco_config"] || values.key?("netdisco") || values.key?("inventory")
106
+
107
+ # 凭据使用本批有效环境,公共环境值按相同优先级交给原生 Settings。
108
+ def netdisco_settings
109
+ return @netdisco_settings if @netdisco_settings
110
+ require "net/connector/netdisco"
111
+ schema = Net::Connector::Netdisco::Config
112
+ sections = %w[netdisco inventory]
113
+ data = values["netdisco_config"] ? external_inventory_data : values.slice(*sections)
114
+ document = schema.parse_sections(data, sections: sections)
115
+ keys = schema.environment_keys
116
+ inventory_keys = schema.environment_keys(sections: sections)
117
+ defaults = @files.file_environment.slice(*inventory_keys).merge(document)
118
+ process = @files.process_environment.slice(*inventory_keys)
119
+ secrets = environment.reject { |key, _| keys.include?(key) || key == "NC_CONFIG" }
120
+ @inventory_public = schema.from_environment(defaults.merge(process), sections: sections)
121
+ @netdisco_settings = Net::Connector::Netdisco::Settings.new(env: secrets, defaults: defaults, overrides: process).for_run(mode: :inventory)
122
+ end
123
+
124
+ # 外部清单同样按文件来源解析路径,并在预检时固定本批配置。
125
+ def external_inventory_data
126
+ Net::Connector::Config.new(name: "backup", env: {}, directories: [], config: values.fetch("netdisco_config"),
127
+ path_fields: %w[netdisco.cache_directory netdisco.db_sslrootcert],
128
+ path_literals: { "netdisco.db_sslrootcert" => [ "system" ] }).load.data
129
+ end
130
+ private :external_inventory_data
131
+
132
+ # 展示外部清单的有效公开字段,预览与执行共用本批快照。
133
+ def public_values
134
+ return values unless values["netdisco_config"]
135
+ netdisco_settings
136
+ values.merge("inventory_configuration" => @inventory_public)
137
+ end
138
+
139
+ # 只初始化缺失文件,排他创建防止覆盖现场配置;不读取设备清单。
140
+ def initialize_files
141
+ template = File.read(File.join(__dir__, "templates/vxlan.yml"))
142
+ @config, @env_file = @files.init_config, @files.init_env_file
143
+ @values = Net::Connector::Config.merge(YAML.safe_load(template), { "directory" => @directory, "parallel" => 4 },
144
+ environment_values(@files.file_environment), environment_values(@files.process_environment), @overrides)
145
+ validate!
146
+ # 公共 NC_PARALLEL 保持继承;只有本工具显式覆盖才写入 YAML。
147
+ parallel_source = [ @files.process_environment, @files.file_environment ].find do |source|
148
+ source["VXLAN_PARALLEL"] || source["NC_PARALLEL"]
149
+ end
150
+ overrides_parallel = @overrides.key?("parallel") || parallel_source&.fetch("VXLAN_PARALLEL", nil)
151
+ document = overrides_parallel ? values : values.reject { |key, _| key == "parallel" }
152
+ template = template.lines.select { |line| line.start_with?("#") }.join +
153
+ Net::Connector::Config.dump(document, name: "vxlan", path: config)
154
+ { config => template, env_file => File.read(File.join(__dir__, "templates/env.example")) }.each do |path, body|
155
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
156
+ begin
157
+ Tempfile.create([ ".vxlan-init-", ".tmp" ], File.dirname(path)) do |file|
158
+ file.chmod(0o600)
159
+ file.write(body)
160
+ file.flush
161
+ file.fsync
162
+ File.link(file.path, path)
163
+ end
164
+ @out.puts "已创建:#{path}"
165
+ rescue Errno::EEXIST
166
+ @out.puts "已保留:#{path}"
167
+ end
168
+ end
169
+ @out.puts "请检查配置及凭据后再次运行;本次未读取清单、未连接设备。"
170
+ end
171
+
172
+ # 默认仅在真实终端提供初始化选择;自动化从不消费 stdin。
173
+ def missing?(input:, error:)
174
+ @files.load
175
+ @config, @env_file = @files.config, @files.env_file
176
+ return false unless @files.missing?
177
+ if input.tty? && error.tty? && flags[:interactive] != false
178
+ error.print "配置不存在:#{config};是否创建配置与环境模板?[y/N] "
179
+ error.flush
180
+ if input.gets&.strip&.match?(/\Ay(?:es)?\z/i)
181
+ initialize_files
182
+ return true
183
+ end
184
+ end
185
+ raise ArgumentError, "配置不存在:#{config};请执行 --init 后填写设备,或使用 --config 指定配置"
186
+ end
187
+
188
+ # 公开配置输出与实际清单复用身份预检,不读取远端清单。
189
+ def normalize_devices(rows)
190
+ raise ArgumentError, "devices 须为数组" unless rows.is_a?(Array)
191
+ targets = rows.map { |row| normalize_device(row) }
192
+ raise ArgumentError, "设备清单包含重复管理地址" unless targets.map { |row| row.fetch("host") }.uniq.size == targets.size
193
+ targets
194
+ end
195
+
196
+ private
197
+
198
+ # 环境值只映射公开字段;非法低优先级值可以被更高层覆盖,最终统一校验。
199
+ def environment_values(source, dynamic: false, higher: {})
200
+ result = {}
201
+ { "VXLAN_FABRIC" => "fabric", "VXLAN_DIRECTORY" => "directory" }.each do |key, field|
202
+ result[field] = source[key] if source.key?(key)
203
+ end
204
+ parallel = source["VXLAN_PARALLEL"] || source["NC_PARALLEL"]
205
+ result["parallel"] = Integer(parallel, exception: false) || parallel if parallel
206
+ ssh = {}
207
+ { "NC_HOST_KEY_POLICY" => "host_key_policy", "NC_KNOWN_HOSTS" => "known_hosts" }.each do |key, field|
208
+ ssh[field] = source[key] if source.key?(key)
209
+ end
210
+ result["ssh"] = ssh if ssh.any?
211
+ if dynamic
212
+ require "net/connector/netdisco/config"
213
+ result.merge!(Net::Connector::Netdisco::Config.from_environment(source, sections: %w[netdisco inventory], higher: higher))
214
+ end
215
+ result
216
+ end
217
+
218
+ # 设备身份只接受公开字段,拒绝隐藏在 YAML 中的密码或连接参数。
219
+ def normalize_device(row)
220
+ unless row.is_a?(Hash) && (row.keys - %w[host name model]).empty? && row["host"].is_a?(String) && !row["host"].include?("/")
221
+ raise ArgumentError, "devices 仅接受 host、name、model,host 须为单个 IP"
222
+ end
223
+ host = IPAddr.new(row.fetch("host")).to_s
224
+ %w[name model].each { |key| raise ArgumentError, "设备 #{key} 须为非空字符串" if row.key?(key) && !row[key].nil? && (!row[key].is_a?(String) || row[key].strip.empty?) }
225
+ { "host" => host, "name" => row["name"] || host, "model" => row["model"] }.freeze
226
+ rescue IPAddr::Error
227
+ raise ArgumentError, "设备 host 无效", cause: nil
228
+ end
229
+
230
+ # 显式声明短参数与长参数;不提供难以发现的兼容缩写。
231
+ def parser
232
+ OptionParser.new do |p|
233
+ p.banner = "用法:#{@command} [选项](只读 VXLAN 采集)"
234
+ p.on("--config FILE", "最高优先级 YAML,叠加系统/用户/脚本/工作目录配置") { |v| @explicit_config = File.expand_path(v) }
235
+ p.on("--env-file FILE", "最高优先级环境文件;进程环境仍优先") { |v| @explicit_env_file = File.expand_path(v) }
236
+ p.on("-p", "--parallel N", Integer, "设备并发 1~500;继承 NC_PARALLEL,未配置时 4") { |v| @overrides["parallel"] = v }
237
+ p.on("--directory DIR", "按设备保存报告的目录") { |v| @overrides["directory"] = File.expand_path(v) }
238
+ p.on("--fabric-policy FILE", "显式 Fabric/Leaf/Border/Spine 清单 YAML") { |v| @overrides["fabric_policy"] = File.expand_path(v) }
239
+ p.on("--fabric NAME", "采集所属 Fabric 身份") { |v| @overrides["fabric"] = v }
240
+ p.on("--dimensions LIST", "逗号分隔维度;--help 查看支持项") { |v| @overrides["dimensions"] = list(v) }
241
+ { "--host IP" => "hosts", "--hosts LIST" => "hosts", "--exclude-hosts LIST" => "exclude_hosts",
242
+ "--name LIST" => "names", "--exclude-name LIST" => "exclude_names" }.each do |option, key|
243
+ p.on(option, "筛选已有清单,同类条件取并集,排除优先") { |v| (@overrides[key] ||= []).concat(list(v)) }
244
+ end
245
+ p.on("--init", "创建缺失配置和环境模板后退出;已有文件不覆盖") { flags[:init] = true }
246
+ p.on("--interactive", "缺少配置时询问是否创建;要求真实终端") { flags[:interactive] = true }
247
+ p.on("--non-interactive", "禁用问答;创建文件须同时指定 --init") { flags[:interactive] = false }
248
+ p.on("--plan", "--inventory-only", "预览筛选清单,不连接设备") { flags[:inventory_only] = true }
249
+ p.on("--show-config", "查看公开有效配置") { flags[:show_config] = true }
250
+ p.on("--json", "stdout 输出 JSON;默认显示可读摘要") { flags[:json] = true }
251
+ p.on("--quiet", "--no-progress", "关闭 stderr 进度,可配合 --json") { flags[:quiet] = true }
252
+ p.on("--[no-]scripts", "可选导出配置建议,默认仅诊断") { |value| flags[:scripts] = value }
253
+ p.on("--verbose", "显示逐台结果及安全命令事件") { flags[:verbose] = true }
254
+ p.on("-h", "--help", "查看选项帮助") { flags[:help] = true }
255
+ p.on("--help-all", "查看全部选项、路径与环境约定") { flags[:help_all] = true }
256
+ p.on("--examples", "查看常用命令范例,不执行任务") { flags[:examples] = true }
257
+ p.separator "维度:#{Vxlan::Collector::COMMANDS.keys.join(', ')}"
258
+ p.separator "优先级:CLI > 进程环境 > YAML > .env > 内置默认值。退出码:0 完整/预览;1 未完成/无目标;2 参数/持久化错误;130 取消。"
259
+ end
260
+ end
261
+
262
+ # 列表中的空项属于输入错误,不能被静默删掉。
263
+ def list(value)
264
+ values = value.split(",", -1).map(&:strip)
265
+ raise ArgumentError, "列表不能有空项" if values.any?(&:empty?)
266
+ values
267
+ end
268
+
269
+ # 先验证整批策略,避免在前面设备连接后才发现后面的非法配置。
270
+ def validate!
271
+ raise ArgumentError, "parallel 须为 1~500" unless values["parallel"].is_a?(Integer) && (1..500).cover?(values["parallel"])
272
+ %w[fabric directory].each { |key| raise ArgumentError, "#{key} 须为非空字符串" unless values[key].is_a?(String) && !values[key].strip.empty? }
273
+ dimensions = values["dimensions"]
274
+ unless dimensions.is_a?(Array) && dimensions.any? && dimensions.all?(String) && dimensions.uniq == dimensions && (dimensions - Vxlan::Collector::COMMANDS.keys.map(&:to_s)).empty?
275
+ raise ArgumentError, "dimensions 包含未知、重复或空维度"
276
+ end
277
+ values["devices"] = normalize_devices(values["devices"])
278
+ inline = %w[netdisco inventory].any? { |key| values.key?(key) }
279
+ %w[netdisco inventory].each do |key|
280
+ raise ArgumentError, "#{key} 须为映射" if values.key?(key) && !values[key].is_a?(Hash)
281
+ end
282
+ if inline && (!values["devices"].empty? || values["netdisco_config"])
283
+ raise ArgumentError, "静态 devices、内嵌 Netdisco 和外部 netdisco_config 不能同时声明"
284
+ end
285
+ if values["netdisco_config"]
286
+ raise ArgumentError, "netdisco_config 须为配置路径,且不能同时声明 devices" unless values["netdisco_config"].is_a?(String) && !values["netdisco_config"].empty? && values["devices"].empty?
287
+ values["netdisco_config"] = File.expand_path(values["netdisco_config"], Dir.pwd)
288
+ end
289
+ %w[hosts exclude_hosts names exclude_names].each do |key|
290
+ items = values[key] = values.fetch(key, [])
291
+ raise ArgumentError, "#{key} 须为非空字符串数组" unless items.is_a?(Array) && items.all? { |v| v.is_a?(String) && !v.strip.empty? }
292
+ end
293
+ %w[hosts exclude_hosts].each { |key| values[key].each { |v| IPAddr.new(v) } }
294
+ ssh = values["ssh"]
295
+ raise ArgumentError, "ssh 包含未知或敏感字段" unless ssh.is_a?(Hash) && (ssh.keys - SSH_FIELDS).empty?
296
+ ssh = SSH_DEFAULTS.merge(ssh)
297
+ raise ArgumentError, "host_key_policy 须为 strict 或 accept_new" unless %w[strict accept_new].include?(ssh["host_key_policy"])
298
+ %w[command_timeout login_timeout].each { |key| raise ArgumentError, "#{key} 须为正数" unless ssh[key].is_a?(Numeric) && ssh[key].finite? && ssh[key].positive? }
299
+ raise ArgumentError, "port 须为 1~65535" if ssh.key?("port") && (!ssh["port"].is_a?(Integer) || !(1..65535).cover?(ssh["port"]))
300
+ if ssh.key?("known_hosts")
301
+ raise ArgumentError, "known_hosts 须为路径" unless ssh["known_hosts"].is_a?(String) && !ssh["known_hosts"].empty?
302
+ ssh["known_hosts"] = File.expand_path(ssh["known_hosts"], Dir.pwd)
303
+ end
304
+ values["ssh"] = ssh
305
+ values["directory"] = File.expand_path(values["directory"], Dir.pwd)
306
+ rescue IPAddr::Error
307
+ raise ArgumentError, "hosts 须为 IP 或 CIDR,不展开地址、不扫描网段", cause: nil
308
+ end
309
+ end
310
+ end
311
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+ require "time"
6
+ require "vxlan"
7
+
8
+ module Vxlan
9
+ module CLI
10
+ # 离线解析显式提供的文件或 stdin,不自行假定传输完整。
11
+ class Parse
12
+ # 注入标准流,便于在宿主测试中验证无需网络的行为。
13
+ def initialize(out: $stdout, error: $stderr, input: $stdin, command: "vxlan parse")
14
+ @out, @error, @input, @command = out, error, input, command
15
+ end
16
+
17
+ # 参数错误与部分证据分开返回;默认只显示元数据,JSON 显式导出事实。
18
+ def run(argv)
19
+ options = { dimension: :configuration, complete: false }
20
+ parser = OptionParser.new do |p|
21
+ p.banner = "用法:#{@command} [选项](离线文件或 stdin)"
22
+ p.on("--file FILE", "读取文件,默认 stdin") { |v| options[:file] = v }
23
+ p.on("--dimension NAME", "configuration 或运行维度") { |v| options[:dimension] = v.to_sym }
24
+ p.on("--model NAME", "设备型号") { |v| options[:model] = v }
25
+ p.on("--complete", "声明输入已完整采集,仍保留解析缺失诊断") { options[:complete] = true }
26
+ p.on("--json", "输出事实 JSON,可能包含配置敏感信息") { options[:json] = true }
27
+ p.on("-h", "--help", "查看帮助") { options[:help] = true }
28
+ p.on("--help-all", "查看全部选项") { options[:help] = true }
29
+ p.on("--examples", "查看示例") { options[:examples] = true }
30
+ p.separator "维度:#{Vxlan::Collector::COMMANDS.keys.join(', ')}"
31
+ end
32
+ rest = parser.parse(argv.dup)
33
+ raise ArgumentError, "不接受位置参数" unless rest.empty?
34
+ return @out.puts(parser) || 0 if options[:help]
35
+ return @out.puts("#{@command} --file running.cfg --complete\ncat nve.txt | #{@command} --dimension nve_peers --complete --json") || 0 if options[:examples]
36
+ raise ArgumentError, "未知维度" unless Vxlan::Collector::COMMANDS.key?(options[:dimension])
37
+
38
+ body = options[:file] ? File.read(options[:file]) : @input.read
39
+ observation = if options[:dimension] == :configuration
40
+ Vxlan::Config.new(model: options[:model]).parse(body, complete: options[:complete])
41
+ else
42
+ Vxlan::RuntimeParser.new(model: options[:model]).parse(options[:dimension], body, complete: options[:complete])
43
+ end
44
+ if options[:json]
45
+ @out.puts JSON.pretty_generate(observation.to_h)
46
+ else
47
+ @out.puts "维度:#{observation.name};证据:#{observation.status};记录:#{observation.records.size}"
48
+ @out.puts "诊断:#{observation.issues.map { |issue| issue[:code] }.uniq.join(', ')}" unless observation.issues.empty?
49
+ @out.puts "证据完整性不代表 Fabric 健康。"
50
+ end
51
+ observation.complete? ? 0 : 1
52
+ rescue OptionParser::ParseError, ArgumentError, SystemCallError => error
53
+ @error.puts "离线解析参数或文件错误(#{error.class.name});使用 --help 查看参数"
54
+ 2
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "tempfile"
6
+ require "fileutils"
7
+ require_relative "review/report"
8
+ require_relative "review/fabric"
9
+
10
+ module Vxlan
11
+ module CLI
12
+ # 宿主按批次保存事实;配置事实可能敏感,目录 0700、文件 0600。
13
+ class Report
14
+ include Review::Report
15
+ include Review::Fabric
16
+
17
+ attr_reader :directory
18
+
19
+ # 采集和离线审查共用稳定名称,保留 IPv6 地址并避免设备名成为路径。
20
+ def self.device_basename(device)
21
+ "#{device.fetch('name').gsub(/[^A-Za-z0-9_.-]/, '_')[0, 100]}-#{device.fetch('host').gsub(/[^A-Za-z0-9_.-]/, '_')}"
22
+ end
23
+
24
+ # 仅交付发布阶段,不保留底层异常正文或敏感报告内容。
25
+ class Error < IOError
26
+ attr_reader :relative, :phase
27
+
28
+ # 保留安全的发布阶段与提交状态,不携带底层异常正文。
29
+ def initialize(relative:, phase:, committed:)
30
+ @relative, @phase, @committed = relative.freeze, phase, committed
31
+ super("报告写入失败:#{relative};阶段 #{phase};#{committed ? '已替换,收尾未确认' : '尚未替换'}")
32
+ end
33
+
34
+ # 区分原子替换已完成与目录同步尚未确认。
35
+ def committed? = @committed
36
+ end
37
+
38
+ # 批次目录排他创建,重复时间戳不会覆盖上一批次。
39
+ def initialize(root, scripts: false)
40
+ @scripts = scripts
41
+ root = File.expand_path(root)
42
+ prepare_directory(root)
43
+ stat = File.lstat(root)
44
+ unless stat.directory? && (stat.mode & 0o077).zero?
45
+ raise ArgumentError, "报告根目录须为真实私有目录,权限0700;请设置独立目录并检查 chmod 0700"
46
+ end
47
+ stem = Time.now.utc.strftime("%Y-%m-%d_%H-%M-%S")
48
+ sequence = 0
49
+ begin
50
+ @directory = File.join(root, sequence.zero? ? stem : "#{stem}_#{sequence}")
51
+ Dir.mkdir(directory, 0o700)
52
+ rescue Errno::EEXIST
53
+ sequence += 1
54
+ retry
55
+ end
56
+ sync_directory(root)
57
+ end
58
+
59
+ # 每台完成后立即原子保存,即使批次随后取消仍可读取已完成证据。
60
+ def write(result, snapshot: nil)
61
+ device = result.fetch(:device)
62
+ name = self.class.device_basename(device)
63
+ # 保存状态属于批次宿主;不能让正在发布的文件预先声明自身已保存。
64
+ body = result.reject { |key, _| key == :report_saved }.merge(snapshot: snapshot && snapshot.to_h)
65
+ document = JSON.parse(JSON.generate(serializable(body)))
66
+ advice = report_advice(Recommendations.new(document).call)
67
+ document["recommendations"] = advice
68
+ json = JSON.pretty_generate(document) + "\n"
69
+ prepare_directory(File.join(directory, "evidence"))
70
+ publish("evidence/#{name}.json", json)
71
+ write_review(document, basename: name, source: File.join(directory, "evidence", "#{name}.json"), source_sha256: Digest::SHA256.hexdigest(json), advice: advice)
72
+ end
73
+
74
+ # 摘要不嵌入配置事实,只汇总状态与诊断,供终端和自动化共用。
75
+ def finish(results, interrupted: false, requested: [], report_error: nil, execution_error: nil, output_error: nil)
76
+ @recovery_failed = false
77
+ # 按地址索引一次,避免大批次逐个扫描所有已完成结果。
78
+ completed_hosts = results.to_h { |result| [ result.fetch(:device).fetch("host"), true ] }
79
+ pending = requested.reject { |device| completed_hosts.key?(device.fetch("host")) }
80
+ complete = !report_error && !execution_error && !output_error && pending.empty? && results.any? && results.all? { |r| r[:status] == :complete && r[:report_saved] != false }
81
+ summary = { status: interrupted ? :interrupted : (complete ? :complete : :incomplete),
82
+ total: [ requested.size, results.size ].max, completed: results.size, pending: pending, counts: results.map { |r| r[:status] }.tally, devices: results,
83
+ directory: directory, evidence_only: true }
84
+ summary[:review] = @review_summary if @review_summary
85
+ summary[:report_error] = report_error if report_error
86
+ summary[:execution_error] = execution_error if execution_error
87
+ summary[:output_error] = output_error if output_error
88
+ # 先保存未完成检查点;文本发布或最终提交失败都不能留下成功摘要。
89
+ checkpoint = summary.merge(status: interrupted ? :interrupted : :incomplete,
90
+ report_error: report_error || :report_finalization_unconfirmed)
91
+ checkpoint_body = JSON.pretty_generate(serializable(checkpoint)) + "\n"
92
+ begin
93
+ publish("summary.json", checkpoint_body)
94
+ rescue Error, Interrupt
95
+ invalidate_completed_summary
96
+ raise
97
+ end
98
+ text = [ "VXLAN 证据:#{summary.fetch(:status)};完成 #{results.size} 台", "分类:#{summary.fetch(:counts)}", "证据完整性不代表 Fabric 健康。" ]
99
+ text << "逐设备诊断:devices/;候选脚本:scripts/;所有候选配置未执行。"
100
+ text << @review_text if @review_text
101
+ text << "批次报告状态:参见 summary.json;文本中的证据状态不确认报告发布完成。"
102
+ text << "报告错误:#{report_error}" if report_error
103
+ text << "执行错误:#{execution_error}" if execution_error
104
+ text << "进度输出错误:#{output_error[:error_type]};设备结果已保留" if output_error
105
+ results.each do |r|
106
+ saved = r[:report_saved] == true ? "已保存" : "保存未确认"
107
+ text << "#{r[:device]['host']}\t#{r[:device]['name']}\t#{r[:status]}\t#{saved}\t#{r[:error]}"
108
+ end
109
+ pending.each { |device| text << "#{device['host']}\t#{device['name']}\t未采集" }
110
+ publish("summary.txt", text.join("\n") + "\n")
111
+ begin
112
+ publish("summary.json", JSON.pretty_generate(serializable(summary)) + "\n")
113
+ rescue Error => error
114
+ # 未替换时原检查点仍安全,不做可能破坏它的多余恢复写入。
115
+ restore_checkpoint(checkpoint_body) if error.committed?
116
+ raise
117
+ rescue Interrupt
118
+ checkpoint[:status] = :interrupted
119
+ restore_checkpoint(JSON.pretty_generate(serializable(checkpoint)) + "\n")
120
+ raise
121
+ end
122
+ summary
123
+ end
124
+
125
+ # 每次发送配置前后发布检查点;失败由执行器停止后续写入。
126
+ def write_execution(state)
127
+ publish("execution.json", JSON.pretty_generate(state) + "\n")
128
+ end
129
+
130
+ # 恢复写入或删除受阻时,调用方须把退出码及此状态作为未确认诊断。
131
+ def recovery_failed? = @recovery_failed == true
132
+
133
+ private
134
+
135
+ # 新建目录的名字属于父目录;文件 fsync 不能替代这条持久化链。
136
+ def prepare_directory(path)
137
+ missing, parent = [], path
138
+ until File.directory?(parent)
139
+ missing << parent
140
+ parent = File.dirname(parent)
141
+ end
142
+ FileUtils.mkdir_p(path, mode: 0o700)
143
+ missing.reverse_each do |created|
144
+ parent = File.dirname(created)
145
+ sync_directory(File.symlink?(parent) ? File.realpath(parent) : parent)
146
+ end
147
+ end
148
+
149
+ # 批次创建与文件发布共享目录同步边界,失败直接交还宿主停止执行。
150
+ def sync_directory(path)
151
+ File.open(path, File::RDONLY | File::NOFOLLOW) { |parent| parent.fsync }
152
+ end
153
+
154
+ # 新一次收尾失败时,旧成功摘要不再代表当前状态;安全检查点继续保留。
155
+ def invalidate_completed_summary
156
+ Thread.handle_interrupt(Exception => :never) do
157
+ saved = JSON.parse(File.read(File.join(directory, "summary.json")))
158
+ discard_summary if saved["status"] == "complete"
159
+ end
160
+ rescue Errno::ENOENT
161
+ nil
162
+ rescue SystemCallError, JSON::ParserError, Interrupt
163
+ @recovery_failed = true
164
+ end
165
+
166
+ # 屏蔽恢复中的次级取消,始终把最先观察到的发布失败或取消交还调用方。
167
+ def restore_checkpoint(body, relative: "summary.json")
168
+ Thread.handle_interrupt(Exception => :never) do
169
+ begin
170
+ publish(relative, body)
171
+ rescue Error, Interrupt => error
172
+ @recovery_failed = true
173
+ discard_summary(relative: relative) unless error.is_a?(Error) && error.committed?
174
+ end
175
+ end
176
+ rescue Interrupt
177
+ @recovery_failed = true
178
+ end
179
+
180
+ # 存储拒绝恢复时尽力移除不可信摘要,不删除已获得的设备事实。
181
+ def discard_summary(relative: "summary.json")
182
+ File.unlink(File.join(directory, relative))
183
+ rescue Errno::ENOENT
184
+ nil
185
+ rescue SystemCallError
186
+ @recovery_failed = true
187
+ end
188
+
189
+ # Time 使用 ISO-8601,递归转换 Data 只发生在显式持久化边界。
190
+ def serializable(value)
191
+ case value
192
+ when Time then value.iso8601(6)
193
+ when Data then serializable(value.to_h)
194
+ when Hash then value.transform_values { |item| serializable(item) }
195
+ when Array then value.map { |item| serializable(item) }
196
+ else value
197
+ end
198
+ end
199
+
200
+ # 临时文件与目标在同一目录,写入失败不发布半份报告。
201
+ def publish(relative, body)
202
+ target = File.join(directory, relative)
203
+ phase, committed = :temporary_file, false
204
+ Tempfile.create([ ".vxlan-", ".tmp" ], File.dirname(target)) do |file|
205
+ file.chmod(0o600)
206
+ phase = :write
207
+ file.write(body)
208
+ phase = :file_sync
209
+ file.flush
210
+ file.fsync
211
+ phase = :rename
212
+ File.rename(file.path, target)
213
+ committed = true
214
+ phase = :directory_sync
215
+ sync_directory(File.dirname(target))
216
+ phase = :cleanup
217
+ end
218
+ rescue SystemCallError, IOError, NotImplementedError
219
+ raise Error.new(relative: relative, phase: phase, committed: committed), cause: nil
220
+ end
221
+ end
222
+ end
223
+ end