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.
@@ -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, "配置回读失败" unless capture.success?
28
+ raise IOError, "Configuration readback failed" unless capture.success?
27
29
  observation = Vxlan::Config.new.parse(capture.body, complete: true)
28
- raise ArgumentError, "配置证据不完整" unless observation.complete? && observation.records.one?
29
- JSON.parse(JSON.generate(observation.records.first))
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, "设备命令未确认" unless result.success? && result.steps.one?
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 = "用法:vxlan apply --plan FILE --item ID [--env-file FILE](必须真实终端明确确认)"
54
- p.on("--plan FILE", "批次 plan.json,须有同目录 SHA256 校验文件") { |value| options[:plan] = value }
55
- p.on("--item ID", "批次计划中明确选择一个增量,禁止批量执行") { |value| options[:item] = value }
56
- p.on("--env-file FILE", "显式凭据文件,进程环境优先") { |value| options[:env_file] = value }
57
- p.on("--directory DIR", "私有执行日志目录") { |value| options[:directory] = value }
58
- p.on("--port N", Integer, "SSH 端口,默认 22") { |value| options[:port] = value }
59
- p.on("-h", "--help", "查看帮助,不连接设备") { options[:help] = true }
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, "需要单个计划" unless rest.empty? && options[:plan] && options[:port].between?(1, 65_535)
66
- body = File.read(options[:plan])
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 "目标:#{plan['device']['name']}(#{host});#{plan['scope']};#{plan['title']}"
72
- @out.puts "影响:#{plan['impact']}"
73
- @out.puts Array(plan["prerequisites"]).map { |value| "条件:#{value}" }
74
- @out.puts "计划 SHA256:#{hash}\n拟执行命令:\n#{change.commands.join("\n")}\n条件回退(不自动执行):\n#{change.rollback.join("\n")}"
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: "确认业务策略及维护窗口后输入 #{token};其他输入取消:")
77
- return @out.puts("已取消,未连接设备。") || 1 unless confirmed
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 "配置回读已确认;仍需完成业务验收,未执行保存配置。日志:#{@store.directory}/execution.json"
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 "操作中断;#{@execution&.started? ? '设备效果可能不完整,请先回读核对,禁止重放。' : '尚未下发业务配置。'}"
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 "下发停止(#{error.class.name});#{@execution&.started? ? '效果未完整确认,先回读,不自动重放或回退。' : '未下发业务配置;检查终端、计划、凭据及当前配置是否仍匹配。'}"
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, "需要批次计划及有效 --item"
103
+ raise ArgumentError, "A batch plan and valid --item are required"
102
104
  end
103
- raise ArgumentError, "计划校验失败" unless File.read("#{options[:plan]}.sha256").strip == hash
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, "计划类型不匹配" unless plan.is_a?(Hash) && plan["schema_version"] == 1 && plan["platform"] == "cisco_nxos"
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, "目标地址无效" unless host.is_a?(String) && !host.include?("/") && IPAddr.new(host).to_s == host
112
- raise ArgumentError, "配置指纹无效" unless plan["expected_config_sha256"].is_a?(String) && plan["expected_config_sha256"].match?(/\A[0-9a-f]{64}\z/)
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, "计划命令被修改" unless change.commands == plan["commands"] && change.rollback == plan["rollback"]
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, "缺少设备用户名" unless username.is_a?(String) && !username.strip.empty?
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
@@ -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, "联网采集需要 net-connector >= 0.17.1;请在宿主 Gemfile 添加或 gem install net-connector", cause: nil
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
- Net::Connector::Resources.prepare!(parallel: [ devices.size, options.values.fetch("parallel") ].min)
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) = Report.new(options.values.fetch("directory"), scripts: options.flags[:scripts] == true)
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 ArgumentError, "请设置 NC_DEVICE_USERNAME 或 NC_CISCO_NXOS_USERNAME;密码可通过环境或 SSH key/agent 提供" unless username
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
@@ -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/initialization"
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, "不接受位置参数" unless rest.empty? }
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 "环境默认:VXLAN_CONFIG、VXLAN_FABRIC、VXLAN_PARALLEL(或 NC_PARALLEL)、VXLAN_DIRECTORY。"
38
- @out.puts "凭据:NC_DEVICE_USERNAME / NC_DEVICE_PASSWORD,或 NC_CISCO_NXOS_USERNAME / NC_CISCO_NXOS_PASSWORD;可选 NC_ENABLE_PASSWORD。"
39
- @out.puts "优先级:CLI > 进程环境 > YAML > .env > 内置默认值。YAML/.env 路径基于来源目录;CLI 路径基于启动目录。默认报告:#{@directory}"
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 "常用示例(地址须替换为实际清单地址):\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"
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 ArgumentError, "--interactive 需要交互终端;自动化请使用 --init --non-interactive"
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, "采集配置需要 net-connector >= 0.17.1;读取 .env 还需要 dotenv", cause: nil
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 只接受公开配置字段" unless (data.keys - FIELDS).empty?
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 须为路径" unless values["fabric_policy"].is_a?(String)
94
- policy = YAML.safe_load(File.read(values["fabric_policy"]), aliases: false)
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 无效或包含不允许的类型", cause: nil
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::Initialization.new(prefix: ".vxlan-init-").create(path: path, contents: body)
158
- @out.puts "#{status == :created ? '已创建' : '已保留'}:#{path}"
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("配置不存在:#{config};是否创建配置与环境模板?[y/N] ")
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, "配置不存在:#{config};请执行 --init 后填写设备,或使用 --config 指定配置"
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 须为数组" unless rows.is_a?(Array)
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, "设备清单包含重复管理地址" unless targets.map { |row| row.fetch("host") }.uniq.size == targets.size
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 仅接受 host、name、model,host 须为单个 IP"
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, "设备 #{key} 须为非空字符串" if row.key?(key) && !row[key].nil? && (!row[key].is_a?(String) || row[key].strip.empty?) }
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, "设备 host 无效", cause: nil
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 = "用法:#{@command} [选项](只读 VXLAN 采集)"
225
- CtxCli::Options.configuration(p, parallel_help: "设备并发 1~500;继承 NC_PARALLEL,未配置时 4") do |key, value|
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", "显式 Fabric/Leaf/Border/Spine 清单 YAML") { |v| @overrides["fabric_policy"] = File.expand_path(v) }
234
- p.on("--fabric NAME", "采集所属 Fabric 身份") { |v| @overrides["fabric"] = v }
235
- p.on("--dimensions LIST", "逗号分隔维度;--help 查看支持项") { |v| @overrides["dimensions"] = list(v) }
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", "预览筛选清单,不连接设备") { flags[:inventory_only] = true }
245
- p.on("--show-config", "查看公开有效配置") { flags[:show_config] = true }
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", "可选导出配置建议,默认仅诊断") { |value| flags[:scripts] = value }
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 "维度:#{Vxlan::Collector::COMMANDS.keys.join(', ')}"
250
- p.separator "优先级:CLI > 进程环境 > YAML > .env > 内置默认值。退出码:0 完整/预览;1 未完成/无目标;2 参数/持久化错误;130 取消。"
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 须为 1~500" unless values["parallel"].is_a?(Integer) && (1..500).cover?(values["parallel"])
260
- %w[fabric directory].each { |key| raise ArgumentError, "#{key} 须为非空字符串" unless values[key].is_a?(String) && !values[key].strip.empty? }
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} 须为映射" if values.key?(key) && !values[key].is_a?(Hash)
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, "静态 devices、内嵌 Netdisco 和外部 netdisco_config 不能同时声明"
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 须为配置路径,且不能同时声明 devices" unless values["netdisco_config"].is_a?(String) && !values["netdisco_config"].empty? && values["devices"].empty?
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 包含未知或敏感字段" unless ssh.is_a?(Hash) && (ssh.keys - SSH_FIELDS).empty?
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 须为 strict 或 accept_new" unless %w[strict accept_new].include?(ssh["host_key_policy"])
283
- %w[command_timeout login_timeout].each { |key| raise ArgumentError, "#{key} 须为正数" unless ssh[key].is_a?(Numeric) && ssh[key].finite? && ssh[key].positive? }
284
- raise ArgumentError, "port 须为 1~65535" if ssh.key?("port") && (!ssh["port"].is_a?(Integer) || !(1..65535).cover?(ssh["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 须为路径" unless ssh["known_hosts"].is_a?(String) && !ssh["known_hosts"].empty?
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 须为 IP 或 CIDR,不展开地址、不扫描网段", cause: nil
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
@@ -20,18 +20,18 @@ module Vxlan
20
20
 
21
21
  # 参数错误与部分证据分开返回;默认只显示元数据,JSON 显式导出事实。
22
22
  def run(argv)
23
- command = CtxCli::Command.new(banner: "用法:#{@command} [选项](离线文件或 stdin)", defaults: { dimension: :configuration, complete: false }, out: @out) do |p, options|
24
- p.on("--file FILE", "读取文件,默认 stdin") { |v| options[:file] = v }
25
- p.on("--dimension NAME", "configuration 或运行维度") { |v| options[:dimension] = v.to_sym }
26
- p.on("--model NAME", "设备型号") { |v| options[:model] = v }
27
- p.on("--complete", "声明输入已完整采集,仍保留解析缺失诊断") { options[:complete] = true }
28
- p.on("--json", "输出事实 JSON,可能包含配置敏感信息") { options[:json] = true }
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 "维度:#{Vxlan::Collector::COMMANDS.keys.join(', ')}"
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, "未知维度" unless Vxlan::Collector::COMMANDS.key?(options[:dimension])
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 "维度:#{observation.name};证据:#{observation.status};记录:#{observation.records.size}"
46
- @out.puts "诊断:#{observation.issues.map { |issue| issue[:code] }.uniq.join(', ')}" unless observation.issues.empty?
47
- @out.puts "证据完整性不代表 Fabric 健康。"
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("离线解析参数或文件错误;使用 --help 查看参数", exception: error)
51
+ @output.diagnostic("Offline parsing argument or file error; use --help for options", exception: error)
52
52
  2
53
53
  end
54
54
  end
@@ -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 = JSON.parse(JSON.generate(serializable(body)))
40
+ document = CtxRunner::Document.call(body)
40
41
  advice = report_advice(Recommendations.new(document).call)
41
- document["recommendations"] = advice
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(:pending)
53
- text = [ "VXLAN 证据:#{summary.fetch(:status)};完成 #{results.size} 台", "分类:#{summary.fetch(:counts)}", "证据完整性不代表 Fabric 健康。" ]
54
- text << "逐设备诊断:devices/;候选脚本:scripts/;所有候选配置未执行。"
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 << "批次报告状态:参见 summary.json;文本中的证据状态不确认报告发布完成。"
57
- text << "报告错误:#{report_error}" if report_error
58
- text << "执行错误:#{execution_error}" if execution_error
59
- text << "进度输出错误:#{output_error[:error_type]};设备结果已保留" if output_error
60
- results.each do |r|
61
- saved = r[:report_saved] == true ? "已保存" : "保存未确认"
62
- text << "#{r[:device]['host']}\t#{r[:device]['name']}\t#{r[:status]}\t#{saved}\t#{r[:error]}"
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']}\t未采集" }
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