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 +4 -4
- data/CHANGELOG.md +6 -0
- data/README-zh.md +1 -1
- data/README.md +1 -1
- data/bin/README.md +2 -2
- data/lib/vxlan/change_plan/svi_acl.rb +7 -7
- data/lib/vxlan/change_plan.rb +7 -7
- data/lib/vxlan/cli/apply.rb +28 -28
- data/lib/vxlan/cli/collect.rb +4 -4
- data/lib/vxlan/cli/options.rb +42 -41
- data/lib/vxlan/cli/parse.rb +12 -12
- data/lib/vxlan/cli/report.rb +11 -11
- data/lib/vxlan/cli/review/fabric.rb +21 -21
- data/lib/vxlan/cli/review/report.rb +28 -28
- data/lib/vxlan/cli/review.rb +14 -14
- data/lib/vxlan/cli.rb +2 -2
- data/lib/vxlan/fabric/review.rb +42 -42
- data/lib/vxlan/fabric/scope.rb +6 -6
- data/lib/vxlan/recommendations.rb +26 -26
- data/lib/vxlan/version.rb +1 -1
- metadata +15 -21
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3711abc03628af1231bcd4698cff27e399db8b596a4d5e6ec46712bfc43f38fb
|
|
4
|
+
data.tar.gz: b57592e473ca59bfcf3c04a8abf6ed92a34bbd05a2d7d9e683be005437860f6d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
16
|
-
raise ArgumentError, "ACL
|
|
17
|
-
raise ArgumentError, "
|
|
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
|
|
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
|
|
22
|
-
raise ArgumentError, "
|
|
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
|
data/lib/vxlan/change_plan.rb
CHANGED
|
@@ -15,12 +15,12 @@ module Vxlan
|
|
|
15
15
|
|
|
16
16
|
# 类型判别只发生在输入边界,不接受任意脚本或未声明字段。
|
|
17
17
|
def self.build(change)
|
|
18
|
-
raise ArgumentError, "
|
|
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, "
|
|
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
|
|
70
|
-
raise ArgumentError, "VNI
|
|
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
|
|
data/lib/vxlan/cli/apply.rb
CHANGED
|
@@ -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, "
|
|
28
|
+
raise IOError, "Configuration readback failed" unless capture.success?
|
|
29
29
|
observation = Vxlan::Config.new.parse(capture.body, complete: true)
|
|
30
|
-
raise ArgumentError, "
|
|
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, "
|
|
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 = "
|
|
56
|
-
p.on("--plan FILE", "
|
|
57
|
-
p.on("--item ID", "
|
|
58
|
-
p.on("--env-file FILE", "
|
|
59
|
-
p.on("--directory DIR", "
|
|
60
|
-
p.on("--port N", Integer, "SSH
|
|
61
|
-
p.on("-h", "--help", "
|
|
55
|
+
p.banner = "Usage: vxlan apply --plan FILE --item ID [--env-file FILE] (requires explicit confirmation in an interactive terminal)"
|
|
56
|
+
p.on("--plan FILE", "Batch plan.json; requires a SHA256 checksum file in the same directory") { |value| options[:plan] = value }
|
|
57
|
+
p.on("--item ID", "Explicitly select one change from the batch plan; bulk execution is prohibited") { |value| options[:item] = value }
|
|
58
|
+
p.on("--env-file FILE", "Explicit credentials file; process environment takes precedence") { |value| options[:env_file] = value }
|
|
59
|
+
p.on("--directory DIR", "Private execution log directory") { |value| options[:directory] = value }
|
|
60
|
+
p.on("--port N", Integer, "SSH port, default 22") { |value| options[:port] = value }
|
|
61
|
+
p.on("-h", "--help", "Show help without connecting to devices") { options[:help] = true }
|
|
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, "
|
|
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 "
|
|
74
|
-
@out.puts "
|
|
75
|
-
@out.puts Array(plan["prerequisites"]).map { |value| "
|
|
76
|
-
@out.puts "
|
|
73
|
+
@out.puts "Target: #{plan['device']['name']}(#{host}); #{plan['scope']}; #{plan['title']}"
|
|
74
|
+
@out.puts "Impact: #{plan['impact']}"
|
|
75
|
+
@out.puts Array(plan["prerequisites"]).map { |value| "Prerequisite: #{value}" }
|
|
76
|
+
@out.puts "Plan SHA256: #{hash}\nProposed commands:\n#{change.commands.join("\n")}\nConditional rollback (not executed automatically):\n#{change.rollback.join("\n")}"
|
|
77
77
|
token = "APPLY #{host} #{hash[0, 12]} #{options[:item]}"
|
|
78
|
-
confirmed = terminal.confirm(token: token, prompt: "
|
|
79
|
-
return @out.puts("
|
|
78
|
+
confirmed = terminal.confirm(token: token, prompt: "After confirming the service policy and maintenance window, enter #{token}; any other input cancels: ")
|
|
79
|
+
return @out.puts("Cancelled; no device connection opened.") || 1 unless confirmed
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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, "
|
|
103
|
+
raise ArgumentError, "A batch plan and valid --item are required"
|
|
104
104
|
end
|
|
105
|
-
raise ArgumentError, "
|
|
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, "
|
|
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, "
|
|
114
|
-
raise ArgumentError, "
|
|
113
|
+
raise ArgumentError, "Invalid target address" unless host.is_a?(String) && !host.include?("/") && IPAddr.new(host).to_s == host
|
|
114
|
+
raise ArgumentError, "Invalid configuration fingerprint" unless plan["expected_config_sha256"].is_a?(String) && plan["expected_config_sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
115
115
|
change = ChangePlan.build(plan.fetch("change"))
|
|
116
|
-
raise ArgumentError, "
|
|
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, "
|
|
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
|
data/lib/vxlan/cli/collect.rb
CHANGED
|
@@ -34,7 +34,7 @@ module Vxlan
|
|
|
34
34
|
def load_network
|
|
35
35
|
require "net/connector"
|
|
36
36
|
rescue LoadError
|
|
37
|
-
raise LoadError, "
|
|
37
|
+
raise LoadError, "Online collection requires net-connector >= 0.18.0; add it to the host Gemfile or run gem install net-connector", cause: nil
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
# YAML 静态设备或显式 Netdisco 来源二选一;所有重复地址在连接前拒绝。
|
|
@@ -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, "
|
|
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, "
|
|
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, "
|
|
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
|
data/lib/vxlan/cli/options.rb
CHANGED
|
@@ -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, "
|
|
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 "
|
|
40
|
-
@out.puts "
|
|
41
|
-
@out.puts "
|
|
40
|
+
@out.puts "Environment defaults: VXLAN_CONFIG, VXLAN_FABRIC, VXLAN_PARALLEL (or NC_PARALLEL), VXLAN_DIRECTORY."
|
|
41
|
+
@out.puts "Credentials: NC_DEVICE_USERNAME / NC_DEVICE_PASSWORD, or NC_CISCO_NXOS_USERNAME / NC_CISCO_NXOS_PASSWORD; optional NC_ENABLE_PASSWORD."
|
|
42
|
+
@out.puts "Precedence: CLI > process environment > YAML > .env > built-in defaults. YAML/.env paths are relative to their source directory; CLI paths are relative to the startup directory. Default reports: #{@directory}"
|
|
42
43
|
end
|
|
43
44
|
elsif flags[:examples]
|
|
44
|
-
@out.puts "
|
|
45
|
+
@out.puts "Common examples (replace addresses with actual inventory addresses):\n Initialize missing files and exit:\n #{@command} --init --non-interactive\n Preview filtered inventory:\n #{@command} --host 192.0.2.10 --plan\n Select devices by name:\n #{@command} --name leaf --exclude-name spare -p 4\n Automated collection summary:\n #{@command} --dimensions configuration,nve_peers --json --no-progress"
|
|
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
|
|
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, "
|
|
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
|
|
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
|
|
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
|
|
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 ? '
|
|
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("
|
|
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, "
|
|
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
|
|
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, "
|
|
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
|
|
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, "
|
|
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, "
|
|
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 = "
|
|
227
|
-
CtxCli::Options.configuration(p, parallel_help: "
|
|
227
|
+
p.banner = "Usage: #{@command} [options] (read-only VXLAN collection)"
|
|
228
|
+
CtxCli::Options.configuration(p, parallel_help: "Device concurrency 1 to 500; inherits NC_PARALLEL, defaults to 4") do |key, value|
|
|
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", "
|
|
236
|
-
p.on("--fabric NAME", "
|
|
237
|
-
p.on("--dimensions LIST", "
|
|
236
|
+
p.on("--fabric-policy FILE", "Explicit Fabric/Leaf/Border/Spine inventory YAML") { |v| @overrides["fabric_policy"] = File.expand_path(v) }
|
|
237
|
+
p.on("--fabric NAME", "Fabric identity for this collection") { |v| @overrides["fabric"] = v }
|
|
238
|
+
p.on("--dimensions LIST", "Comma-separated dimensions; see --help for supported values") { |v| @overrides["dimensions"] = list(v) }
|
|
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", "
|
|
247
|
-
p.on("--show-config", "
|
|
247
|
+
p.on("--plan", "--inventory-only", "Preview filtered inventory without connecting to devices") { flags[:inventory_only] = true }
|
|
248
|
+
p.on("--show-config", "Show effective public configuration") { flags[:show_config] = true }
|
|
248
249
|
CtxCli::Options.output(p, flags)
|
|
249
|
-
p.on("--[no-]scripts", "
|
|
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 "
|
|
252
|
-
p.separator "
|
|
252
|
+
p.separator "Dimensions: #{Vxlan::Collector::COMMANDS.keys.join(', ')}"
|
|
253
|
+
p.separator "Precedence: CLI > process environment > YAML > .env > built-in defaults. Exit codes: 0 complete/preview; 1 incomplete/no targets; 2 argument/persistence error; 130 cancelled."
|
|
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
|
|
262
|
-
%w[fabric directory].each { |key| raise ArgumentError, "#{key}
|
|
262
|
+
raise ArgumentError, "parallel must be 1 to 500" unless values["parallel"].is_a?(Integer) && (1..500).cover?(values["parallel"])
|
|
263
|
+
%w[fabric directory].each { |key| raise ArgumentError, "#{key} must be a nonempty string" unless values[key].is_a?(String) && !values[key].strip.empty? }
|
|
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}
|
|
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, "
|
|
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
|
|
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
|
|
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
|
|
285
|
-
%w[command_timeout login_timeout].each { |key| raise ArgumentError, "#{key}
|
|
286
|
-
raise ArgumentError, "port
|
|
285
|
+
raise ArgumentError, "host_key_policy must be strict or accept_new" unless %w[strict accept_new].include?(ssh["host_key_policy"])
|
|
286
|
+
%w[command_timeout login_timeout].each { |key| raise ArgumentError, "#{key} must be positive" unless ssh[key].is_a?(Numeric) && ssh[key].finite? && ssh[key].positive? }
|
|
287
|
+
raise ArgumentError, "port must be 1 to 65535" if ssh.key?("port") && (!ssh["port"].is_a?(Integer) || !(1..65535).cover?(ssh["port"]))
|
|
287
288
|
if ssh.key?("known_hosts")
|
|
288
|
-
raise ArgumentError, "known_hosts
|
|
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
|
|
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
|
data/lib/vxlan/cli/parse.rb
CHANGED
|
@@ -20,18 +20,18 @@ module Vxlan
|
|
|
20
20
|
|
|
21
21
|
# 参数错误与部分证据分开返回;默认只显示元数据,JSON 显式导出事实。
|
|
22
22
|
def run(argv)
|
|
23
|
-
command = CtxCli::Command.new(banner: "
|
|
24
|
-
p.on("--file FILE", "
|
|
25
|
-
p.on("--dimension NAME", "configuration
|
|
26
|
-
p.on("--model NAME", "
|
|
27
|
-
p.on("--complete", "
|
|
28
|
-
p.on("--json", "
|
|
23
|
+
command = CtxCli::Command.new(banner: "Usage: #{@command} [options] (offline file or stdin)", defaults: { dimension: :configuration, complete: false }, out: @out) do |p, options|
|
|
24
|
+
p.on("--file FILE", "Read a file; defaults to stdin") { |v| options[:file] = v }
|
|
25
|
+
p.on("--dimension NAME", "configuration or a runtime dimension") { |v| options[:dimension] = v.to_sym }
|
|
26
|
+
p.on("--model NAME", "Device model") { |v| options[:model] = v }
|
|
27
|
+
p.on("--complete", "Declare input fully collected; missing parse evidence is still reported") { options[:complete] = true }
|
|
28
|
+
p.on("--json", "Output facts as JSON; may contain sensitive configuration") { options[:json] = true }
|
|
29
29
|
CtxCli::Options.information(p, options)
|
|
30
|
-
p.separator "
|
|
30
|
+
p.separator "Dimensions: #{Vxlan::Collector::COMMANDS.keys.join(', ')}"
|
|
31
31
|
end
|
|
32
32
|
options = command.parse(argv)
|
|
33
33
|
return 0 if command.informational?(examples: "#{@command} --file running.cfg --complete\ncat nve.txt | #{@command} --dimension nve_peers --complete --json")
|
|
34
|
-
raise ArgumentError, "
|
|
34
|
+
raise ArgumentError, "Unknown dimension" unless Vxlan::Collector::COMMANDS.key?(options[:dimension])
|
|
35
35
|
|
|
36
36
|
body = CtxCli::Input.read(path: options[:file], input: @input)
|
|
37
37
|
observation = if options[:dimension] == :configuration
|
|
@@ -42,13 +42,13 @@ module Vxlan
|
|
|
42
42
|
if options[:json]
|
|
43
43
|
@output.json(observation.to_h)
|
|
44
44
|
else
|
|
45
|
-
@out.puts "
|
|
46
|
-
@out.puts "
|
|
47
|
-
@out.puts "
|
|
45
|
+
@out.puts "Dimensions: #{observation.name}; evidence: #{observation.status}; records: #{observation.records.size}"
|
|
46
|
+
@out.puts "Diagnostics: #{observation.issues.map { |issue| issue[:code] }.uniq.join(', ')}" unless observation.issues.empty?
|
|
47
|
+
@out.puts "Complete evidence does not establish Fabric health."
|
|
48
48
|
end
|
|
49
49
|
observation.complete? ? 0 : 1
|
|
50
50
|
rescue OptionParser::ParseError, ArgumentError, SystemCallError, IOError => error
|
|
51
|
-
@output.diagnostic("
|
|
51
|
+
@output.diagnostic("Offline parsing argument or file error; use --help for options", exception: error)
|
|
52
52
|
2
|
|
53
53
|
end
|
|
54
54
|
end
|