vxlan 0.4.2 → 0.5.1

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