vxlan 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d262e92c5f60d01b4acfbe457e3c8b6466354f97414e14f9eff3e7cff2472519
4
- data.tar.gz: 888b2ac2fac38db10ff2b0511861d46f007ccdf47ef98528c4b431734729439a
3
+ metadata.gz: 3711abc03628af1231bcd4698cff27e399db8b596a4d5e6ec46712bfc43f38fb
4
+ data.tar.gz: b57592e473ca59bfcf3c04a8abf6ed92a34bbd05a2d7d9e683be005437860f6d
5
5
  SHA512:
6
- metadata.gz: 13cf4f4d999d82c923f0ae52143c271721ab23cdc24af24fef2bddfcac0f7f8b3517ad1686186b4961814cfbda65cd09e7ab50ceaee044fd4c71a6b8ccd17fa7
7
- data.tar.gz: 7a63e8abd6569a3fc2de95089c42644f2a883d14fe4198f1c14eaa7a376ce14c213433da7950d7911e833a787f3fcda58f364c6ebdd68b9153088a62ba379d26
6
+ metadata.gz: '08f9a10991fb96ebaf09c2e30f4893a056576dcc48d1ad3471021b2c1c41efaab326e18873f008c42153036a545db3ba9be5f427b2e75aa778a6fa54f849795b'
7
+ data.tar.gz: 505286b2466afe7bbe7460680c4c3f791a476f6b5e5b3848ae4a2105c8f2003f92d4005cf16fbed1a581ebd1b711bb7882ed32ad4ff6db195fed2856243336e3
data/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ Changes to the public contract are recorded here. Versions follow Semantic Versi
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.5.0 (2026-10-23)
8
+
9
+ - Consume immutable string-keyed Runner summary documents throughout reports and progress callbacks.
10
+ - Use English diagnostics, CLI help, change plans and Fabric review reports.
11
+ - Require Runner 0.2, net-connector 0.18 and the coordinated command receipt protocol.
12
+
7
13
  ## 0.4.2 (2026-10-23)
8
14
 
9
15
  - Migrate shared CLI entrypoints to ctx_cli 0.2.0 names without legacy aliases.
data/README-zh.md CHANGED
@@ -59,7 +59,7 @@ observation.records.each { |record| p record }
59
59
  ```sh
60
60
  gem install vxlan
61
61
  # 联网采集与读取 .env 所需的可选依赖:
62
- gem install net-connector -v '~> 0.16.0'
62
+ gem install net-connector -v '~> 0.18.0'
63
63
  vxlan --help
64
64
  vxlan collect --init --non-interactive
65
65
  vxlan collect --inventory-only
data/README.md CHANGED
@@ -59,7 +59,7 @@ The gem installs `vxlan`; its implementation lives in `lib/vxlan/cli/`. The scri
59
59
  ```sh
60
60
  gem install vxlan
61
61
  # Optional dependencies for live collection and .env files:
62
- gem install net-connector -v '~> 0.17.1'
62
+ gem install net-connector -v '~> 0.18.0'
63
63
  vxlan --help
64
64
  vxlan collect --init --non-interactive
65
65
  vxlan collect --inventory-only
data/bin/README.md CHANGED
@@ -17,7 +17,7 @@ bundle exec ruby examples/offline.rb
17
17
 
18
18
  `bin/` 的公开内容是 `vxlan` 与本说明;现场 `vxlan.yml`、`.env`、`vxlans/` 由用户按需创建,不进入版本库或安装包。配置和凭据模板统一位于 `lib/vxlan/cli/templates/`,使用 `--init` 生成,不维护重复模板。
19
19
 
20
- 离线解析和帮助不加载 net-connector。采集配置与联网入口可选依赖 `net-connector ~> 0.16.0`(已包含 `.env` 加载依赖 dotenv)。本仓库 Gemfile 的 `examples` 组已声明连接器,不进入 vxlan.gemspec 的运行依赖。只安装 vxlan gem 的宿主需要自行声明相应可选依赖。
20
+ 离线解析和帮助不加载 net-connector。采集配置与联网入口可选依赖 `net-connector ~> 0.18.0`(已包含 `.env` 加载依赖 dotenv)。本仓库 Gemfile 的 `examples` 组已声明连接器,不进入 vxlan.gemspec 的运行依赖。只安装 vxlan gem 的宿主需要自行声明相应可选依赖。
21
21
 
22
22
  ## 正式命令
23
23
 
@@ -25,7 +25,7 @@ bundle exec ruby examples/offline.rb
25
25
 
26
26
  ```sh
27
27
  gem install vxlan
28
- gem install net-connector -v '~> 0.16.0'
28
+ gem install net-connector -v '~> 0.18.0'
29
29
  vxlan --help
30
30
  vxlan collect --init --non-interactive
31
31
  vxlan collect --inventory-only
@@ -12,14 +12,14 @@ module Vxlan
12
12
  def validate_parameters!
13
13
  super
14
14
  name, direction = @change.values_at("acl", "direction")
15
- raise ArgumentError, "ACL 参数无效" unless name.is_a?(String) && name.match?(/\A[A-Za-z0-9_.-]{1,63}\z/) && %w[in out].include?(direction)
16
- raise ArgumentError, "ACL 创建状态无效" unless [ true, false ].include?(@change["create_acl"])
17
- raise ArgumentError, "不能覆盖既有绑定" unless [ nil, name ].include?(@change["previous_binding"])
15
+ raise ArgumentError, "Invalid ACL parameters" unless name.is_a?(String) && name.match?(/\A[A-Za-z0-9_.-]{1,63}\z/) && %w[in out].include?(direction)
16
+ raise ArgumentError, "Invalid ACL creation state" unless [ true, false ].include?(@change["create_acl"])
17
+ raise ArgumentError, "Cannot overwrite an existing binding" unless [ nil, name ].include?(@change["previous_binding"])
18
18
  groups = @change.fetch("address_groups", {})
19
- raise ArgumentError, "ACL 依赖未验证" unless groups.is_a?(Hash)
19
+ raise ArgumentError, "ACL dependencies are unverified" unless groups.is_a?(Hash)
20
20
  @policy = AclPolicy.resolve({ "address_groups" => groups }, @change["entries"])
21
- raise ArgumentError, "ACL 规则或依赖未验证" unless @policy && @policy.address_groups == groups
22
- raise ArgumentError, "无有效 ACL 规则" if @policy.rules.empty?
21
+ raise ArgumentError, "ACL rules or dependencies are unverified" unless @policy && @policy.address_groups == groups
22
+ raise ArgumentError, "No valid ACL rules" if @policy.rules.empty?
23
23
  @rule_commands = rule_commands
24
24
  end
25
25
 
@@ -29,7 +29,7 @@ module Vxlan
29
29
  next if entry["action"] == "remark"
30
30
  action, expression = entry.values_at("action", "expression")
31
31
  unless %w[permit deny].include?(action) && expression.is_a?(String) && expression.match?(/\A(?:ip|tcp|udp|icmp|igmp|gre|esp|ah|ospf) [A-Za-z0-9_.:\/ -]+\z/)
32
- raise ArgumentError, "ACL 规则语法未验证"
32
+ raise ArgumentError, "ACL rule syntax is unverified"
33
33
  end
34
34
  " #{entry['sequence']} #{action} #{expression}"
35
35
  end
@@ -15,12 +15,12 @@ module Vxlan
15
15
 
16
16
  # 类型判别只发生在输入边界,不接受任意脚本或未声明字段。
17
17
  def self.build(change)
18
- raise ArgumentError, "增量须为映射" unless change.is_a?(Hash)
18
+ raise ArgumentError, "Change must be a mapping" unless change.is_a?(Hash)
19
19
  case change["kind"]
20
20
  when "vlan_vni_add" then VlanVni.new(change)
21
21
  when "svi_gateway_add" then SviGateway.new(change)
22
22
  when "svi_acl" then SviAcl.new(change)
23
- else raise ArgumentError, "不支持的增量类型"
23
+ else raise ArgumentError, "Unsupported change type"
24
24
  end
25
25
  end
26
26
 
@@ -34,9 +34,9 @@ module Vxlan
34
34
 
35
35
  # 构造时一次校验和渲染,随后调用者不能改变已确认的参数或命令。
36
36
  def initialize(change)
37
- raise ArgumentError, "请使用 ChangePlan.build 构造业务增量" if instance_of?(ChangePlan)
37
+ raise ArgumentError, "Use ChangePlan.build to construct a domain change" if instance_of?(ChangePlan)
38
38
  unless change.is_a?(Hash) && change.keys.all?(String) && (change.keys - self.class::FIELDS).empty? && change["kind"] == self.class::KIND
39
- raise ArgumentError, "增量字段无效"
39
+ raise ArgumentError, "Invalid change fields"
40
40
  end
41
41
  @change = CtxParser::Immutable.copy(change)
42
42
  validate_parameters!
@@ -66,8 +66,8 @@ module Vxlan
66
66
 
67
67
  # 三种动作均定位已有 L2 业务,VLAN/VNI 不从字符串或名称猜测。
68
68
  def validate_parameters!
69
- raise ArgumentError, "VLAN 无效" unless @change["vlan"].is_a?(Integer) && @change["vlan"].between?(1, 4094)
70
- raise ArgumentError, "VNI 无效" unless @change["vni"].is_a?(Integer) && @change["vni"].between?(1, 16_777_215)
69
+ raise ArgumentError, "Invalid VLAN" unless @change["vlan"].is_a?(Integer) && @change["vlan"].between?(1, 4094)
70
+ raise ArgumentError, "Invalid VNI" unless @change["vni"].is_a?(Integer) && @change["vni"].between?(1, 16_777_215)
71
71
  end
72
72
 
73
73
  # 网关相关增量必须携带明确租户及单个已验证 IPv4 前缀。
@@ -75,7 +75,7 @@ module Vxlan
75
75
  address = @change["address"]
76
76
  unless @change["vrf"].is_a?(String) && !@change["vrf"].empty? && address.is_a?(String) &&
77
77
  address.match?(/\A\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}\z/) && IPAddr.new(address).ipv4?
78
- raise ArgumentError, "缺少已验证的业务网关"
78
+ raise ArgumentError, "Verified service gateway is missing"
79
79
  end
80
80
  end
81
81
 
@@ -25,16 +25,16 @@ module Vxlan
25
25
  def read_configuration
26
26
  [ "terminal length 0", "terminal width 511" ].each { |command| write(command) }
27
27
  capture = CtxDriver::Reader.new(session: @native).read("show running-config")
28
- raise IOError, "配置回读失败" unless capture.success?
28
+ raise IOError, "Configuration readback failed" unless capture.success?
29
29
  observation = Vxlan::Config.new.parse(capture.body, complete: true)
30
- raise ArgumentError, "配置证据不完整" unless observation.complete? && observation.records.one?
30
+ raise ArgumentError, "Configuration evidence is incomplete" unless observation.complete? && observation.records.one?
31
31
  CtxRunner::Document.call(observation.records.first)
32
32
  end
33
33
 
34
34
  # 每次只发一条命令;失败立即交还宿主,原始输出不进入终端和日志报告。
35
35
  def write(command)
36
36
  result = @native.execute_command(command.strip, output_sensitive: true)
37
- raise IOError, "设备命令未确认" unless result.success? && result.steps.one?
37
+ raise IOError, "Device command is unconfirmed" unless result.success? && result.steps.one?
38
38
  true
39
39
  end
40
40
  end
@@ -52,44 +52,44 @@ module Vxlan
52
52
  @store, @execution = nil, nil
53
53
  options = { directory: File.join(Dir.pwd, "vxlan-executions"), port: 22 }
54
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
+ 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 }
62
62
  end
63
63
  rest = parser.parse(argv.dup)
64
64
  return @out.puts(parser) || 0 if options[:help]
65
65
  terminal = CtxCli::Terminal.new(input: @input, out: @out)
66
66
  terminal.require_tty!(@error)
67
- raise ArgumentError, "需要单个计划" unless rest.empty? && options[:plan] && options[:port].between?(1, 65_535)
67
+ raise ArgumentError, "A single plan is required" unless rest.empty? && options[:plan] && options[:port].between?(1, 65_535)
68
68
  body = CtxCli::Input.read(path: options[:plan])
69
69
  hash = Digest::SHA256.hexdigest(body)
70
70
  plan = CtxParser::Immutable.copy(select_plan(JSON.parse(body), options, hash))
71
71
  change = validate_plan(plan)
72
72
  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")}"
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")}"
77
77
  token = "APPLY #{host} #{hash[0, 12]} #{options[:item]}"
78
- confirmed = terminal.confirm(token: token, prompt: "确认业务策略及维护窗口后输入 #{token};其他输入取消:")
79
- 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
80
80
  settings = credentials(options)
81
81
  @store = Report.new(options[:directory])
82
82
  @execution = CtxRunner::Execution.new(plan: plan, change: change, journal: @store, plan_sha256: hash, item: options[:item])
83
83
  @execution.run { |operation| with_session(plan["device"], settings, &operation) }
84
- @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"
85
85
  0
86
86
  rescue Interrupt
87
87
  report_journal_failure
88
- @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.'}"
89
89
  130
90
90
  rescue StandardError, LoadError => error
91
91
  report_journal_failure
92
- @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.'}"
93
93
  2
94
94
  end
95
95
 
@@ -100,20 +100,20 @@ module Vxlan
100
100
  id = options[:item]
101
101
  unless document.is_a?(Hash) && document["schema_version"] == 2 && document["platform"] == "cisco_nxos" && document["changes"].is_a?(Hash) &&
102
102
  id.is_a?(String) && id.match?(/\A[A-Za-z0-9_.-]+\z/) && document["changes"].key?(id)
103
- raise ArgumentError, "需要批次计划及有效 --item"
103
+ raise ArgumentError, "A batch plan and valid --item are required"
104
104
  end
105
- raise ArgumentError, "计划校验失败" unless CtxCli::Input.read(path: "#{options[:plan]}.sha256", max_bytes: 128).strip == hash
105
+ raise ArgumentError, "Plan checksum verification failed" unless CtxCli::Input.read(path: "#{options[:plan]}.sha256", max_bytes: 128).strip == hash
106
106
  document["changes"].fetch(id)
107
107
  end
108
108
 
109
109
  # 不信任文件中的命令,重新从有限动作渲染并逐字核对,拒绝额外命令。
110
110
  def validate_plan(plan)
111
- 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"
112
112
  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/)
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/)
115
115
  change = ChangePlan.build(plan.fetch("change"))
116
- 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"]
117
117
  change
118
118
  end
119
119
 
@@ -122,7 +122,7 @@ module Vxlan
122
122
  require "net/connector/config"
123
123
  env = Net::Connector::Config.new(name: "vxlan", env: @env, env_file: options[:env_file], **@discovery).load_environment.environment
124
124
  username = env["NC_CISCO_NXOS_USERNAME"] || env["NC_DEVICE_USERNAME"]
125
- 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?
126
126
  { username: username, password: env["NC_CISCO_NXOS_PASSWORD"] || env["NC_DEVICE_PASSWORD"], enable_password: env["NC_ENABLE_PASSWORD"],
127
127
  port: options[:port], host_key_policy: "strict", command_timeout: 60, login_timeout: 15 }
128
128
  end
@@ -139,7 +139,7 @@ module Vxlan
139
139
  # 公共执行器保存失败状态;CLI 只呈现持久性尚未确认的诊断。
140
140
  def report_journal_failure
141
141
  return unless @execution&.journal_failed?
142
- @error.puts "执行日志收尾未确认;保留已有检查点,禁止据此重放配置。"
142
+ @error.puts "Execution log finalization is unconfirmed; existing checkpoints retained. Do not replay configuration based on this state."
143
143
  rescue StandardError, Interrupt
144
144
  nil
145
145
  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