net-connector 0.8.0 → 0.9.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 +12 -0
- data/CONTRIBUTING.md +12 -0
- data/README.md +26 -425
- data/README.zh-CN.md +64 -0
- data/bin/net-backup +1 -0
- data/docs/VERIFICATION.md +2 -2
- data/docs/architecture.md +9 -4
- data/docs/netdisco.md +176 -0
- data/docs/netdisco.zh-CN.md +247 -0
- data/docs/usage.md +124 -0
- data/docs/usage.zh-CN.md +190 -0
- data/examples/backup.yml +5 -4
- data/examples/inventory_sql.yml +5 -1
- data/lib/net/connector/device/base.rb +5 -4
- data/lib/net/connector/device/local_backup.rb +3 -0
- data/lib/net/connector/device/running_config/strategy.rb +2 -1
- data/lib/net/connector/device/running_config.rb +2 -0
- data/lib/net/connector/device/tftp/receipt.rb +1 -0
- data/lib/net/connector/device/tftp/strategy.rb +28 -9
- data/lib/net/connector/device/tftp.rb +9 -8
- data/lib/net/connector/device/topology/immediate_strategy.rb +2 -1
- data/lib/net/connector/engine/core.rb +1 -0
- data/lib/net/connector/engine/dialogue.rb +5 -2
- data/lib/net/connector/engine/known_hosts.rb +20 -4
- data/lib/net/connector/engine/log.rb +14 -9
- data/lib/net/connector/engine/recovery.rb +1 -1
- data/lib/net/connector/engine/session.rb +14 -5
- data/lib/net/connector/engine/transport.rb +21 -10
- data/lib/net/connector/netdisco/backup_run.rb +13 -5
- data/lib/net/connector/netdisco/cli/options.rb +17 -8
- data/lib/net/connector/netdisco/cli.rb +3 -3
- data/lib/net/connector/netdisco/config_file.rb +19 -14
- data/lib/net/connector/netdisco/database_client.rb +40 -24
- data/lib/net/connector/netdisco/device.rb +3 -0
- data/lib/net/connector/netdisco/diagnostic.rb +4 -0
- data/lib/net/connector/netdisco/fleet.rb +2 -2
- data/lib/net/connector/netdisco/plan.rb +12 -3
- data/lib/net/connector/netdisco/progress.rb +9 -0
- data/lib/net/connector/netdisco/report/files.rb +1 -0
- data/lib/net/connector/netdisco/result_store.rb +1 -0
- data/lib/net/connector/netdisco/rules.rb +7 -5
- data/lib/net/connector/netdisco/settings.rb +77 -19
- data/lib/net/connector/netdisco/tftp_archive.rb +4 -0
- data/lib/net/connector/netdisco/worker.rb +1 -1
- data/lib/net/connector/storage/private_file.rb +6 -0
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/cisco_ios/topology.rb +2 -0
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +1 -0
- data/lib/net/connector/vendor/h3c/topology.rb +2 -0
- data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/hillstone/topology.rb +2 -0
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +2 -1
- data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -0
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/palo_alto/topology.rb +16 -16
- data/lib/net/connector/vendor/radware/running_config.rb +1 -0
- data/lib/net/connector/vendor/radware/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/radware/topology.rb +1 -0
- data/lib/net/connector/version.rb +2 -1
- data/lib/net/connector.rb +1 -0
- data/lib/net-connector.rb +1 -0
- metadata +7 -16
|
@@ -129,8 +129,8 @@ module Net
|
|
|
129
129
|
|
|
130
130
|
rules = @rules || settings_snapshot.rules
|
|
131
131
|
devices = rows.map { |row| Device.from_row(row, rules: rules) }
|
|
132
|
-
|
|
133
|
-
devices.map { |device|
|
|
132
|
+
host_counts = devices.each_with_object(Hash.new(0)) { |device, counts| counts[device.host] += 1 if device.host }
|
|
133
|
+
devices.map { |device| host_counts[device.host] > 1 ? device.with(issue: :duplicate_host) : device }.freeze
|
|
134
134
|
end
|
|
135
135
|
|
|
136
136
|
def build_plan(mode, limit, settings_snapshot, allow_fixed_name_reuse: false)
|
|
@@ -25,6 +25,9 @@ module Net
|
|
|
25
25
|
# 按清单顺序返回选中的设备。
|
|
26
26
|
def selected = ready.map(&:last)
|
|
27
27
|
|
|
28
|
+
# 更新与新建共用容器复制边界,不能因 Ruby 版本差异共享可变任务槽位。
|
|
29
|
+
def with(**attributes) = attributes.empty? ? self : self.class.new(**to_h.merge(attributes))
|
|
30
|
+
|
|
28
31
|
# 在任何设备 I/O 前校验完整快照,外部构造或修改的计划也必须遵守相同约束。
|
|
29
32
|
def validate!
|
|
30
33
|
raise ArgumentError, "plan mode must be backup or tftp" unless %i[backup tftp].include?(mode)
|
|
@@ -72,10 +75,16 @@ module Net
|
|
|
72
75
|
|
|
73
76
|
# 外部快照也遵守清单入口的地址唯一性;采样不能把重复设备重新变为可执行。
|
|
74
77
|
def validate_duplicate_hosts!
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
issues_by_host = {}
|
|
79
|
+
inventory.each do |device|
|
|
80
|
+
next unless device.host
|
|
77
81
|
|
|
78
|
-
|
|
82
|
+
host = IPAddr.new(device.host).to_s
|
|
83
|
+
if issues_by_host.key?(host) && (issues_by_host[host] != :duplicate_host || device.issue != :duplicate_host)
|
|
84
|
+
raise ArgumentError, "plan duplicate hosts must be marked as duplicate_host"
|
|
85
|
+
end
|
|
86
|
+
issues_by_host[host] = device.issue
|
|
87
|
+
end
|
|
79
88
|
end
|
|
80
89
|
|
|
81
90
|
# 跳过结果不携带执行产物;文件冲突必须有本批次实际执行的目标作为依据。
|
|
@@ -11,6 +11,7 @@ module Net
|
|
|
11
11
|
class Progress
|
|
12
12
|
attr_reader :output_error
|
|
13
13
|
|
|
14
|
+
# 调用方保留输出 IO 所有权;同一个互斥锁保护心跳、事件和任务计数。
|
|
14
15
|
def initialize(io: $stderr, enabled: true, verbose: false)
|
|
15
16
|
@io, @enabled, @verbose = io, enabled, verbose
|
|
16
17
|
@tty = io.respond_to?(:tty?) && io.tty?
|
|
@@ -27,6 +28,7 @@ module Net
|
|
|
27
28
|
write(nil, "正在读取 Netdisco 设备清单")
|
|
28
29
|
end
|
|
29
30
|
|
|
31
|
+
# 以实际选中设备初始化进度,清单中被跳过的条目不计入执行任务总数。
|
|
30
32
|
def plan(plan, concurrency:, limit_per_vendor: nil)
|
|
31
33
|
@mutex.synchronize do
|
|
32
34
|
@total = plan.ready.size
|
|
@@ -37,6 +39,7 @@ module Net
|
|
|
37
39
|
end
|
|
38
40
|
end
|
|
39
41
|
|
|
42
|
+
# 任务开始时记录单调时钟与登录阶段,不把开始通知当作连接成功。
|
|
40
43
|
def start(device)
|
|
41
44
|
@mutex.synchronize do
|
|
42
45
|
@active += 1
|
|
@@ -75,6 +78,7 @@ module Net
|
|
|
75
78
|
write(fields[:host], message) if message
|
|
76
79
|
end
|
|
77
80
|
|
|
81
|
+
# 只消费最终任务结果,分别展示已保存、设备报告上传和服务器核验状态。
|
|
78
82
|
def result(outcome)
|
|
79
83
|
@mutex.synchronize do
|
|
80
84
|
@completed += 1
|
|
@@ -104,6 +108,7 @@ module Net
|
|
|
104
108
|
end
|
|
105
109
|
end
|
|
106
110
|
|
|
111
|
+
# 使用报告的成功策略与受控失败分类,进度百分比不代替业务成功判定。
|
|
107
112
|
def finish(report)
|
|
108
113
|
return unless @enabled
|
|
109
114
|
|
|
@@ -148,6 +153,7 @@ module Net
|
|
|
148
153
|
end
|
|
149
154
|
end
|
|
150
155
|
|
|
156
|
+
# 心跳仅在持锁后刷新显示,不改变设备任务或完成计数。
|
|
151
157
|
def tick
|
|
152
158
|
@mutex.synchronize { refresh(force: true) }
|
|
153
159
|
end
|
|
@@ -182,6 +188,7 @@ module Net
|
|
|
182
188
|
format("%02d:%02d:%02d", total / 3600, total / 60 % 60, total % 60)
|
|
183
189
|
end
|
|
184
190
|
|
|
191
|
+
# 吞吐与预计剩余时间使用单调时钟;异构设备的估算不能视为任务期限。
|
|
185
192
|
def timing_line
|
|
186
193
|
elapsed = [monotonic - (@run_started || @started_at), 0].max
|
|
187
194
|
rate = elapsed.positive? ? @completed / elapsed : 0
|
|
@@ -200,6 +207,7 @@ module Net
|
|
|
200
207
|
line
|
|
201
208
|
end
|
|
202
209
|
|
|
210
|
+
# TTY 原地刷新最多每秒五次;详细事件和重定向输出采用独立显示路径。
|
|
203
211
|
def refresh(force: false)
|
|
204
212
|
return unless @enabled && @tty && !@verbose
|
|
205
213
|
|
|
@@ -230,6 +238,7 @@ module Net
|
|
|
230
238
|
line
|
|
231
239
|
end
|
|
232
240
|
|
|
241
|
+
# 擦除前一次实际显示的行数,避免短文本或窄终端留下旧状态。
|
|
233
242
|
def clear_refresh
|
|
234
243
|
return unless @refreshing
|
|
235
244
|
|
|
@@ -9,6 +9,7 @@ module Net
|
|
|
9
9
|
class Report
|
|
10
10
|
# 示例与批次入口共享唯一摘要结构;保存结果先于终端成功提示。
|
|
11
11
|
class Files
|
|
12
|
+
# 文本和 JSON 分别提交;失败时尝试保存诊断,并保留已提交的 JSON 位置。
|
|
12
13
|
def self.write(report, directory:, plan:, concurrency:)
|
|
13
14
|
destination = File.join(directory, "summary.json")
|
|
14
15
|
finalized = report.with(report_location: destination)
|
|
@@ -94,11 +94,13 @@ module Net
|
|
|
94
94
|
|
|
95
95
|
# 根据地址、显式规则和厂商信息识别连接器。
|
|
96
96
|
def resolve(row)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
unless @host_overrides.empty?
|
|
98
|
+
begin
|
|
99
|
+
address = IPAddr.new(row["ip"]).to_s
|
|
100
|
+
return @host_overrides.fetch(address) if @host_overrides.key?(address)
|
|
101
|
+
rescue IPAddr::Error, TypeError
|
|
102
|
+
# 无效的管理地址交由 Device.from_row 标记。
|
|
103
|
+
end
|
|
102
104
|
end
|
|
103
105
|
label = normalize(row["vendor"])
|
|
104
106
|
os = normalize(row["os"])
|
|
@@ -16,21 +16,24 @@ module Net
|
|
|
16
16
|
DEFAULT_BACKUP_DIRECTORY = "backups"
|
|
17
17
|
DEFAULT_CONCURRENCY = 4
|
|
18
18
|
MAX_CONCURRENCY = 50
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
# 这里只列可从环境读取的公开策略;凭据单独动态读取,其他内部键只供 YAML/CLI 使用。
|
|
20
|
+
ENVIRONMENT_KEYS = %w[ND_URL].freeze
|
|
21
21
|
DEFAULT_PAGE_SIZE = Client::DEFAULTS.fetch(:page_size)
|
|
22
22
|
UNSET = Object.new.freeze
|
|
23
23
|
private_constant :UNSET
|
|
24
24
|
|
|
25
|
+
# 加载无凭据 YAML 作为默认策略,保留传入环境作为动态凭据来源。
|
|
25
26
|
def self.from_file(path, env: ENV)
|
|
26
27
|
new(env: env, defaults: ConfigFile.load(path))
|
|
27
28
|
end
|
|
28
29
|
|
|
30
|
+
# 只有 NC_CONFIG 显式指定时才读取文件,不隐式加载 .env。
|
|
29
31
|
def self.from_env(env: ENV)
|
|
30
32
|
path = env["NC_CONFIG"]
|
|
31
33
|
path && !path.empty? ? from_file(path, env: env) : new(env: env)
|
|
32
34
|
end
|
|
33
35
|
|
|
36
|
+
# 分开保存默认策略、显式覆盖与凭据来源;fixed 实例只承载已复制的公开快照。
|
|
34
37
|
def initialize(env: ENV, defaults: {}, overrides: {}, policy: nil, fixed: false)
|
|
35
38
|
@env, @defaults, @overrides = env, defaults, overrides
|
|
36
39
|
@policy, @fixed = policy, fixed
|
|
@@ -38,7 +41,14 @@ module Net
|
|
|
38
41
|
|
|
39
42
|
# 调用入口补充运行目录等非敏感覆盖,凭据仍从同一来源读取。
|
|
40
43
|
def with_overrides(values)
|
|
41
|
-
|
|
44
|
+
if @fixed
|
|
45
|
+
public_values = @env.merge(@overrides).merge(values).slice(*non_secret_keys)
|
|
46
|
+
.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
|
|
47
|
+
return self.class.new(env: public_values, fixed: true).freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
policy = @policy.with_overrides(values) if @policy
|
|
51
|
+
self.class.new(env: @env, defaults: @defaults, overrides: @overrides.merge(values), policy: policy)
|
|
42
52
|
end
|
|
43
53
|
|
|
44
54
|
# 快照自身不保留 ENV、凭据 resolver 或原 Settings 的引用,不能携带秘密进入摘要。
|
|
@@ -61,6 +71,7 @@ module Net
|
|
|
61
71
|
self.class.new(env: @env, defaults: @defaults, overrides: @overrides, policy: snapshot(mode: mode))
|
|
62
72
|
end
|
|
63
73
|
|
|
74
|
+
# 按业务模式预检所有策略;离线导出仅验证目录,不读取清单或设备凭据。
|
|
64
75
|
def validate!(mode:)
|
|
65
76
|
unless %i[backup tftp inventory show_config export].include?(mode)
|
|
66
77
|
raise ArgumentError, "unknown settings validation mode"
|
|
@@ -73,7 +84,9 @@ module Net
|
|
|
73
84
|
concurrency
|
|
74
85
|
limit_per_vendor
|
|
75
86
|
log_directory
|
|
87
|
+
tftp_root
|
|
76
88
|
connection_options
|
|
89
|
+
database_options
|
|
77
90
|
Net::Connector.vendors.each { |vendor| connection_options(vendor) }
|
|
78
91
|
tftp_source_files.each_value { |source| TftpTarget.validate_source_file!(source) }
|
|
79
92
|
tftp_vrfs
|
|
@@ -81,6 +94,7 @@ module Net
|
|
|
81
94
|
self
|
|
82
95
|
end
|
|
83
96
|
|
|
97
|
+
# 用固定策略创建清单客户端,但每次读取最新身份凭据;显式认证覆盖默认认证来源。
|
|
84
98
|
def client(policy: snapshot(mode: :inventory), credentials: nil)
|
|
85
99
|
if credentials && (!credentials.is_a?(Hash) || credentials.keys.sort != %i[password username] ||
|
|
86
100
|
!credentials.values.all? { |value| present?(value) })
|
|
@@ -88,28 +102,29 @@ module Net
|
|
|
88
102
|
end
|
|
89
103
|
options = policy.client_options
|
|
90
104
|
if policy.inventory_source == :postgres
|
|
91
|
-
connection = DatabaseClient::CONNECTION_ENV.each_with_object(
|
|
105
|
+
connection = DatabaseClient::CONNECTION_ENV.each_with_object(policy.database_options.dup) do |(key, name), values|
|
|
92
106
|
item = secret_value(name)
|
|
93
107
|
values[key] = item if item
|
|
94
108
|
end
|
|
95
109
|
connection.merge!(user: credentials[:username], password: credentials[:password]) if credentials
|
|
96
110
|
return DatabaseClient.new(connection_options: connection, **options)
|
|
97
111
|
end
|
|
98
|
-
raise ArgumentError, "
|
|
112
|
+
raise ArgumentError, "ND_URL is required" unless options.fetch(:url)
|
|
99
113
|
|
|
100
114
|
authentication = if credentials
|
|
101
115
|
credentials
|
|
102
|
-
elsif (token = secret_value("
|
|
116
|
+
elsif (token = secret_value("ND_API_KEY"))
|
|
103
117
|
{ api_key: token }
|
|
104
118
|
else
|
|
105
|
-
{ username: required_secret("
|
|
119
|
+
{ username: required_secret("ND_USERNAME"), password: required_secret("ND_PASSWORD") }
|
|
106
120
|
end
|
|
107
121
|
Client.new(**options, **authentication)
|
|
108
122
|
end
|
|
109
123
|
|
|
124
|
+
# 清单来源来自 YAML/CLI,HTTP 与 PostgreSQL 共用后续计划和执行接口。
|
|
110
125
|
def inventory_source
|
|
111
|
-
source = value("
|
|
112
|
-
raise ArgumentError, "
|
|
126
|
+
source = value("ND_SOURCE") || "http"
|
|
127
|
+
raise ArgumentError, "ND_SOURCE must be http or postgres" unless %w[http postgres].include?(source)
|
|
113
128
|
|
|
114
129
|
source.to_sym
|
|
115
130
|
end
|
|
@@ -121,6 +136,7 @@ module Net
|
|
|
121
136
|
inventory_source == :postgres ? postgres_options(options) : http_options(options)
|
|
122
137
|
end
|
|
123
138
|
|
|
139
|
+
# 将筛选和映射策略交给 Rules 校验,未识别设备由清单结果单独标记。
|
|
124
140
|
def rules
|
|
125
141
|
Rules.new(include_hosts: list("NC_INCLUDE_HOSTS"),
|
|
126
142
|
exclude_hosts: list("NC_EXCLUDE_HOSTS"),
|
|
@@ -130,6 +146,7 @@ module Net
|
|
|
130
146
|
mappings: json("NC_DEVICE_RULES", []))
|
|
131
147
|
end
|
|
132
148
|
|
|
149
|
+
# 将备份目录解析为绝对路径;这里只校验配置,不创建目录。
|
|
133
150
|
def backup_directory
|
|
134
151
|
path = raw("NC_BACKUP_DIRECTORY", DEFAULT_BACKUP_DIRECTORY)
|
|
135
152
|
raise ArgumentError, "NC_BACKUP_DIRECTORY must be nonempty" unless present?(path)
|
|
@@ -137,6 +154,7 @@ module Net
|
|
|
137
154
|
File.expand_path(path)
|
|
138
155
|
end
|
|
139
156
|
|
|
157
|
+
# 与 Worker 共用并发上限,非法值在任务与目录创建前失败。
|
|
140
158
|
def concurrency
|
|
141
159
|
count = integer("NC_CONCURRENCY", DEFAULT_CONCURRENCY)
|
|
142
160
|
raise ArgumentError, "NC_CONCURRENCY must be at most #{MAX_CONCURRENCY}" if count > MAX_CONCURRENCY
|
|
@@ -144,6 +162,7 @@ module Net
|
|
|
144
162
|
count
|
|
145
163
|
end
|
|
146
164
|
|
|
165
|
+
# 显式 nil 表示全量;tftp: true 且未设置时采用五台上限,示例入口另外选择全量。
|
|
147
166
|
def limit_per_vendor(tftp: false)
|
|
148
167
|
count = if configured?("NC_SAMPLE_PER_VENDOR")
|
|
149
168
|
integer("NC_SAMPLE_PER_VENDOR", 5) unless raw("NC_SAMPLE_PER_VENDOR").nil?
|
|
@@ -154,6 +173,7 @@ module Net
|
|
|
154
173
|
count
|
|
155
174
|
end
|
|
156
175
|
|
|
176
|
+
# 华为批次默认上传 startup.cfg;H3C 省略源文件时由设备策略探测。
|
|
157
177
|
def tftp_source_files
|
|
158
178
|
files = { huawei: raw("NC_HUAWEI_TFTP_SOURCE_FILE", "flash:/startup.cfg") }
|
|
159
179
|
%i[h3c h3c_wireless].each do |vendor|
|
|
@@ -163,6 +183,7 @@ module Net
|
|
|
163
183
|
files
|
|
164
184
|
end
|
|
165
185
|
|
|
186
|
+
# 按厂商读取 VRF 映射,不能把不支持 VRF 的厂商静默忽略。
|
|
166
187
|
def tftp_vrfs
|
|
167
188
|
mapping = json("NC_TFTP_VRFS", {})
|
|
168
189
|
raise ArgumentError, "NC_TFTP_VRFS must be a JSON object" unless mapping.is_a?(Hash)
|
|
@@ -170,6 +191,7 @@ module Net
|
|
|
170
191
|
self.class.validate_vrfs!(mapping.transform_keys(&:to_sym))
|
|
171
192
|
end
|
|
172
193
|
|
|
194
|
+
# 只允许 NX-OS、山石的安全单一 VRF 名称,禁止命令拼接字符。
|
|
173
195
|
def self.validate_vrfs!(vrfs)
|
|
174
196
|
unless vrfs.is_a?(Hash) && vrfs.all? { |vendor, name|
|
|
175
197
|
%i[cisco_nxos hillstone].include?(vendor) && name.is_a?(String) &&
|
|
@@ -180,8 +202,32 @@ module Net
|
|
|
180
202
|
vrfs
|
|
181
203
|
end
|
|
182
204
|
|
|
183
|
-
|
|
205
|
+
# 服务器地址只来自 YAML/CLI 的公开策略,不从 NC_TFTP_HOST 环境变量读取。
|
|
206
|
+
def tftp_server = value("NC_TFTP_HOST")
|
|
207
|
+
|
|
208
|
+
# 本机核验根目录属于策略快照,解析路径不证明文件已经上传。
|
|
209
|
+
def tftp_root
|
|
210
|
+
path = value("NC_TFTP_ROOT")
|
|
211
|
+
File.expand_path(path) if path
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# 端口、TLS 与连接超时进入固定策略;主机、数据库名和登录凭据仍从环境读取。
|
|
215
|
+
def database_options
|
|
216
|
+
options = {}
|
|
217
|
+
%i[port connect_timeout].each do |name|
|
|
218
|
+
key = "ND_DB_#{name.to_s.upcase}"
|
|
219
|
+
next unless configured?(key)
|
|
184
220
|
|
|
221
|
+
options[name] = integer(key, nil)
|
|
222
|
+
end
|
|
223
|
+
%i[sslmode sslrootcert].each do |name|
|
|
224
|
+
item = value("ND_DB_#{name.to_s.upcase}")
|
|
225
|
+
options[name] = item if item
|
|
226
|
+
end
|
|
227
|
+
DatabaseClient.connection_policy(**options)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# 经 Configuration 复用连接校验;厂商协议覆盖通用协议,不包含设备凭据。
|
|
185
231
|
def connection_options(vendor = nil)
|
|
186
232
|
configuration = Configuration.new(
|
|
187
233
|
protocol: vendor_protocols[vendor.to_s] || value("NC_PROTOCOL") || "ssh",
|
|
@@ -194,6 +240,7 @@ module Net
|
|
|
194
240
|
options.freeze
|
|
195
241
|
end
|
|
196
242
|
|
|
243
|
+
# 厂商协议必须使用已登记的厂商键和 ssh/telnet,不接受未知映射。
|
|
197
244
|
def vendor_protocols
|
|
198
245
|
values = json("NC_VENDOR_PROTOCOLS", {})
|
|
199
246
|
unless values.is_a?(Hash) && values.all? { |vendor, protocol| Net::Connector.vendors.map(&:to_s).include?(vendor) && %w[ssh telnet].include?(protocol) }
|
|
@@ -202,6 +249,7 @@ module Net
|
|
|
202
249
|
values
|
|
203
250
|
end
|
|
204
251
|
|
|
252
|
+
# 每台任务读取最新厂商凭据,缺失字段回退到通用凭据;无用户名时跳过设备。
|
|
205
253
|
def device_credentials_for(device)
|
|
206
254
|
prefix = "NC_#{device.vendor.to_s.upcase}_"
|
|
207
255
|
username = secret_value(prefix + "USERNAME") || secret_value("NC_DEVICE_USERNAME")
|
|
@@ -212,11 +260,13 @@ module Net
|
|
|
212
260
|
enable_password: secret_value(prefix + "ENABLE_PASSWORD") || secret_value("NC_ENABLE_PASSWORD") }
|
|
213
261
|
end
|
|
214
262
|
|
|
263
|
+
# 会话日志目录属于公开策略;调用方和批次入口负责实际创建。
|
|
215
264
|
def log_directory
|
|
216
265
|
path = value("NC_LOG_DIRECTORY")
|
|
217
266
|
File.expand_path(path) if path
|
|
218
267
|
end
|
|
219
268
|
|
|
269
|
+
# 生成已验证的无凭据配置摘要,不展开原环境或 resolver。
|
|
220
270
|
def public_config
|
|
221
271
|
snapshot(mode: :show_config).config_hash
|
|
222
272
|
end
|
|
@@ -225,9 +275,10 @@ module Net
|
|
|
225
275
|
|
|
226
276
|
protected
|
|
227
277
|
|
|
278
|
+
# 只输出公开策略;数据库身份和设备凭据不得通过配置摘要进入报告。
|
|
228
279
|
def config_hash
|
|
229
280
|
{
|
|
230
|
-
netdisco: client_options.merge(source: inventory_source),
|
|
281
|
+
netdisco: client_options.merge(source: inventory_source, database: database_options),
|
|
231
282
|
backup: { directory: backup_directory, concurrency: concurrency, limit_per_vendor: limit_per_vendor },
|
|
232
283
|
inventory: { include_hosts: list("NC_INCLUDE_HOSTS"),
|
|
233
284
|
exclude_hosts: list("NC_EXCLUDE_HOSTS"),
|
|
@@ -239,10 +290,11 @@ module Net
|
|
|
239
290
|
log_directory: log_directory,
|
|
240
291
|
vendor_protocols: vendor_protocols
|
|
241
292
|
),
|
|
242
|
-
tftp: { server: tftp_server, vrfs: tftp_vrfs.transform_keys(&:to_s), source_files: tftp_source_files }
|
|
293
|
+
tftp: { server: tftp_server, verification_root: tftp_root, vrfs: tftp_vrfs.transform_keys(&:to_s), source_files: tftp_source_files }
|
|
243
294
|
}
|
|
244
295
|
end
|
|
245
296
|
|
|
297
|
+
# 优先读取固定策略或显式覆盖;内部键的名称不表示该键可由进程环境配置。
|
|
246
298
|
def raw(key, default = nil)
|
|
247
299
|
return @policy.raw(key, default) if @policy
|
|
248
300
|
return @overrides.fetch(key) if @overrides.key?(key)
|
|
@@ -260,7 +312,7 @@ module Net
|
|
|
260
312
|
|
|
261
313
|
def client_budget_options(defaults)
|
|
262
314
|
defaults.to_h do |name, default|
|
|
263
|
-
key = "
|
|
315
|
+
key = "ND_#{name.to_s.upcase}"
|
|
264
316
|
parsed = case name
|
|
265
317
|
when :inventory_timeout then number(key, default)
|
|
266
318
|
when :allow_insecure_http then boolean(key, default)
|
|
@@ -271,16 +323,16 @@ module Net
|
|
|
271
323
|
end
|
|
272
324
|
|
|
273
325
|
def postgres_options(options)
|
|
274
|
-
query = value("
|
|
275
|
-
DatabaseClient.options(query: query, query_params: json("
|
|
326
|
+
query = value("ND_QUERY") || (raise ArgumentError, "ND_QUERY is required for postgres")
|
|
327
|
+
DatabaseClient.options(query: query, query_params: json("ND_QUERY_PARAMS", []), **options)
|
|
276
328
|
end
|
|
277
329
|
|
|
278
330
|
def http_options(options)
|
|
279
|
-
if value("
|
|
280
|
-
raise ArgumentError, "SQL query settings require
|
|
331
|
+
if value("ND_QUERY") || value("ND_QUERY_PARAMS")
|
|
332
|
+
raise ArgumentError, "SQL query settings require ND_SOURCE=postgres"
|
|
281
333
|
end
|
|
282
334
|
options = Client.options(**options)
|
|
283
|
-
url = value("
|
|
335
|
+
url = value("ND_URL")
|
|
284
336
|
Client.validate_url!(url, allow_insecure_http: options.fetch(:allow_insecure_http)) if url
|
|
285
337
|
options.merge(url: url).freeze
|
|
286
338
|
end
|
|
@@ -289,11 +341,16 @@ module Net
|
|
|
289
341
|
ConfigFile::FIELDS.values.flat_map { |section| section.values.map(&:first) }
|
|
290
342
|
end
|
|
291
343
|
|
|
344
|
+
# 旧前缀和已移除策略明确报错;错误只包含变量名,不泄露变量值。
|
|
292
345
|
def validate_environment!
|
|
293
346
|
legacy = @env.keys.find { |key| key.start_with?("NET_CONNECTOR_") }
|
|
294
|
-
raise ArgumentError, "#{legacy}
|
|
347
|
+
raise ArgumentError, "#{legacy} is unsupported; use NC_ credentials or YAML via NC_CONFIG" if legacy
|
|
348
|
+
|
|
349
|
+
legacy = @env.keys.find { |key| key.start_with?("NETDISCO_", "TFTP_") }
|
|
350
|
+
raise ArgumentError, "#{legacy} is unsupported; use ND_ credentials or YAML via NC_CONFIG" if legacy
|
|
295
351
|
|
|
296
352
|
removed = non_secret_keys - ENVIRONMENT_KEYS
|
|
353
|
+
removed << "NC_PROGRESS"
|
|
297
354
|
removed += Net::Connector.vendors.map { |vendor| "NC_#{vendor.to_s.upcase}_PROTOCOL" }
|
|
298
355
|
key = removed.find { |name| @env.key?(name) }
|
|
299
356
|
raise ArgumentError, "#{key} is no longer an environment setting; use YAML via NC_CONFIG or CLI options" if key
|
|
@@ -349,6 +406,7 @@ module Net
|
|
|
349
406
|
result if present?(result)
|
|
350
407
|
end
|
|
351
408
|
|
|
409
|
+
# 凭据只从活的环境映射读取并复制,不允许 defaults/overrides 充当秘密来源。
|
|
352
410
|
def secret_value(key)
|
|
353
411
|
result = @env[key]
|
|
354
412
|
raise ArgumentError, "#{key} must be a String" unless result.nil? || result.is_a?(String)
|
|
@@ -17,6 +17,7 @@ module Net
|
|
|
17
17
|
BATCH_DIRECTORY_NAME = /\A\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:_\d{2})?\z/
|
|
18
18
|
private_constant :BATCH_DIRECTORY_NAME
|
|
19
19
|
|
|
20
|
+
# 固定名上传无法保留历史时,在设备上传之前拒绝执行。
|
|
20
21
|
class Unavailable < Net::Connector::Error
|
|
21
22
|
def initialize(host: nil)
|
|
22
23
|
super("a local TFTP root is required to retain a fixed-name upload", code: :tftp_history_unavailable,
|
|
@@ -24,6 +25,7 @@ module Net
|
|
|
24
25
|
end
|
|
25
26
|
end
|
|
26
27
|
|
|
28
|
+
# 设备可能已经上传,归档失败不能撤销或重新执行设备命令。
|
|
27
29
|
class ArchiveFailed < Net::Connector::Error
|
|
28
30
|
def initialize(host: nil)
|
|
29
31
|
super("the uploaded file could not be retained in the batch", code: :tftp_archive_failed,
|
|
@@ -40,6 +42,7 @@ module Net
|
|
|
40
42
|
@verifier = TftpVerification.new(root: @server_root)
|
|
41
43
|
end
|
|
42
44
|
|
|
45
|
+
# 有本机根目录时保留原名并归档;远端服务器使用批次后缀避免重复覆盖。
|
|
43
46
|
def upload_filename(device)
|
|
44
47
|
name = device.tftp_filename
|
|
45
48
|
return name if @server_root || device.vendor == :palo_alto
|
|
@@ -50,6 +53,7 @@ module Net
|
|
|
50
53
|
TftpTarget.validate_path!("#{stem.byteslice(0, TftpTarget::MAX_PATH_BYTES - suffix.bytesize)}#{suffix}")
|
|
51
54
|
end
|
|
52
55
|
|
|
56
|
+
# 上传与归档在同一目标锁内完成,后续失败仍保留可确认的上传回执和提交路径。
|
|
53
57
|
def upload_and_archive(device, started_at:)
|
|
54
58
|
raise Unavailable.new(host: device.host) if device.vendor == :palo_alto && !@server_root
|
|
55
59
|
|
|
@@ -60,7 +60,7 @@ module Net
|
|
|
60
60
|
threads.each do |thread|
|
|
61
61
|
begin
|
|
62
62
|
thread.join
|
|
63
|
-
rescue Exception # rubocop:disable Lint/RescueException --
|
|
63
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 等待全部工作线程停止,不让收尾异常覆盖原中断。
|
|
64
64
|
nil
|
|
65
65
|
end
|
|
66
66
|
end
|
|
@@ -18,8 +18,12 @@ module Net
|
|
|
18
18
|
|
|
19
19
|
def committed? = state != :not_committed
|
|
20
20
|
def durable? = state == :durable
|
|
21
|
+
|
|
22
|
+
# 阶段更新仍校验状态并冻结路径,Ruby 3.2 也不能绕过初始化约束。
|
|
23
|
+
def with(**attributes) = attributes.empty? ? self : self.class.new(**to_h.merge(attributes))
|
|
21
24
|
end
|
|
22
25
|
|
|
26
|
+
# 写入失败仍携带阶段与提交回执,调用方据此区分未替换和已替换文件。
|
|
23
27
|
class WriteError < IOError
|
|
24
28
|
attr_reader :receipt, :code, :underlying_type
|
|
25
29
|
|
|
@@ -41,8 +45,10 @@ module Net
|
|
|
41
45
|
def inspect = "#<#{self.class} state=#{receipt.state} phase=#{receipt.phase}>"
|
|
42
46
|
end
|
|
43
47
|
|
|
48
|
+
# 替换已经提交后的持久化或收尾失败,不能按尚未写入处理。
|
|
44
49
|
class PersistenceError < WriteError; end
|
|
45
50
|
|
|
51
|
+
# 目录同步能力缺失只表示持久性未确认,已替换的目标文件仍然保留。
|
|
46
52
|
class DirectorySyncUnsupported < WriteError
|
|
47
53
|
def initialize(**options)
|
|
48
54
|
super
|
|
@@ -5,6 +5,7 @@ require_relative "../../device/running_config/strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module CiscoIos
|
|
8
|
+
# 清理 IOS 响应头,保留 banner 和配置正文内的命令、时间等原始内容。
|
|
8
9
|
class RunningConfig < Net::Connector::RunningConfig::Rendered
|
|
9
10
|
# 每条命令已有独立响应;正文中的命令示例属于配置,不能按会话回显删除。
|
|
10
11
|
# 只清理首个配置语句之前的时间注释,避免改写 banner 等多行文本。
|
|
@@ -5,6 +5,7 @@ require_relative "../../device/tftp/strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module CiscoIos
|
|
8
|
+
# 通过交互复制运行配置,以设备的复制完成行确认上传。
|
|
8
9
|
class TftpBackup < Tftp::Strategy
|
|
9
10
|
# 在连接前拒绝当前厂商不支持的参数组合。
|
|
10
11
|
def validate_options!(_target, source_file:, vrf: nil)
|
|
@@ -12,6 +13,7 @@ module Net
|
|
|
12
13
|
raise ArgumentError, "Cisco IOS TFTP backup does not use vrf" unless vrf.nil?
|
|
13
14
|
end
|
|
14
15
|
|
|
16
|
+
# 声明本厂商实际导出的配置种类与格式,不推断服务器文件已核验。
|
|
15
17
|
def receipt_metadata(_target, **) = { configuration_kind: :running, format: :cfg }
|
|
16
18
|
|
|
17
19
|
# 构造当前厂商的 TFTP 导出交互脚本。
|
|
@@ -5,6 +5,7 @@ require_relative "../../device/topology/immediate_strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module CiscoIos
|
|
8
|
+
# 基于 CDP 证据规划接口描述,变更后读回确认,再执行独立保存阶段。
|
|
8
9
|
class Topology < Topology::ImmediateStrategy
|
|
9
10
|
# IOS 提供 CDP 邻居、配置描述读取和描述变更。
|
|
10
11
|
def self.supports?(capability)
|
|
@@ -26,6 +27,7 @@ module Net
|
|
|
26
27
|
# 先回到执行视图读回,随后才执行档案中的保存命令。
|
|
27
28
|
def leave_configuration_commands = ["end"]
|
|
28
29
|
|
|
30
|
+
# 只认可本厂商明确的保存完成行,设备提示符返回本身不能证明持久化。
|
|
29
31
|
def persistence_pattern = /\A(?:\[OK\]|Copy complete\.)\z/i
|
|
30
32
|
|
|
31
33
|
# 每个 Device ID 表示一条应由模板解析的邻居记录。
|
|
@@ -5,12 +5,14 @@ require_relative "../../device/tftp/strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module CiscoNxos
|
|
8
|
+
# 在指定 VRF 上传 NX-OS 运行配置,默认使用 management。
|
|
8
9
|
class TftpBackup < Tftp::Strategy
|
|
9
10
|
# 在连接前拒绝当前厂商不支持的参数组合。
|
|
10
11
|
def validate_options!(_target, source_file:, **)
|
|
11
12
|
raise ArgumentError, "Cisco NX-OS does not use source_file" unless source_file.nil?
|
|
12
13
|
end
|
|
13
14
|
|
|
15
|
+
# 声明本厂商实际导出的配置种类与格式,不推断服务器文件已核验。
|
|
14
16
|
def receipt_metadata(_target, **) = { configuration_kind: :running, format: :cfg }
|
|
15
17
|
|
|
16
18
|
# 构造当前厂商的 TFTP 导出交互脚本。
|
|
@@ -5,6 +5,7 @@ require_relative "../../device/topology/immediate_strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module H3c
|
|
8
|
+
# 适配 H3C LLDP 表头与接口视图,数量核对失败时禁止继续规划变更。
|
|
8
9
|
class Topology < Topology::ImmediateStrategy
|
|
9
10
|
TABLE_HEADER = /^\s*(?:System Name\s+Local Interface\b|(?:Local Interface|LocalIf)\s+)/i
|
|
10
11
|
# H3C 提供 LLDP 邻居、配置描述读取和描述变更。
|
|
@@ -24,6 +25,7 @@ module Net
|
|
|
24
25
|
# 返回用户视图读回;保存交给后续独立阶段。
|
|
25
26
|
def leave_configuration_commands = ["return"]
|
|
26
27
|
|
|
28
|
+
# 只认可本厂商明确的保存完成行,设备提示符返回本身不能证明持久化。
|
|
27
29
|
def persistence_pattern = /\ASaved the current configuration to mainboard device successfully\.\z/i
|
|
28
30
|
|
|
29
31
|
# H3C 不同版本的列顺序不同,按表头选对应模板。
|
|
@@ -5,6 +5,7 @@ require_relative "../../device/running_config/strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module Hillstone
|
|
8
|
+
# 山石配置保持原始文本,仅删除已识别的退格分页痕迹。
|
|
8
9
|
class RunningConfig < Net::Connector::RunningConfig::Strategy
|
|
9
10
|
# 去掉山石 CLI 用退格控制符绘制的分页痕迹。
|
|
10
11
|
def clean(text) = text.gsub(/\x00?\x08+[ \t]+\x08+/, "")
|
|
@@ -5,6 +5,7 @@ require_relative "../../device/tftp/strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module Hillstone
|
|
8
|
+
# 导出山石启动配置,并从完成行确认设备实际使用的 dat 文件名。
|
|
8
9
|
class TftpBackup < Tftp::Strategy
|
|
9
10
|
# 山石原生导出的配置文件使用 dat 扩展名。
|
|
10
11
|
def self.file_extension = "dat"
|
|
@@ -17,6 +18,7 @@ module Net
|
|
|
17
18
|
raise ArgumentError, "Hillstone TFTP filename cannot contain a directory"
|
|
18
19
|
end
|
|
19
20
|
|
|
21
|
+
# 声明本厂商实际导出的配置种类与格式,不推断服务器文件已核验。
|
|
20
22
|
def receipt_metadata(target, **)
|
|
21
23
|
{ configuration_kind: :startup, format: :dat, requested_path: target.explicit_path? ? target.path : nil }
|
|
22
24
|
end
|
|
@@ -5,6 +5,7 @@ require_relative "../../device/topology/immediate_strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module Hillstone
|
|
8
|
+
# 核对山石 LLDP 数据与总数,接口描述读回一致后才保存配置。
|
|
8
9
|
class Topology < Topology::ImmediateStrategy
|
|
9
10
|
# 山石提供 LLDP 邻居、配置描述读取和描述变更。
|
|
10
11
|
def self.supports?(capability)
|
|
@@ -23,6 +24,7 @@ module Net
|
|
|
23
24
|
# 退出配置视图后先读回,保存动作随后单独执行。
|
|
24
25
|
def leave_configuration_commands = ["exit"]
|
|
25
26
|
|
|
27
|
+
# 只认可本厂商明确的保存完成行,设备提示符返回本身不能证明持久化。
|
|
26
28
|
def persistence_pattern = /\ASaving configuration is finished\.?\z/i
|
|
27
29
|
|
|
28
30
|
# 用数据行和设备报告的总数交叉核对,避免遗漏邻居。
|